diff --git a/.github/scripts/maven_publish.sh b/.github/scripts/maven_publish.sh index 655ad5c69..8e63903a2 100644 --- a/.github/scripts/maven_publish.sh +++ b/.github/scripts/maven_publish.sh @@ -43,6 +43,7 @@ echo "settings.xml written." echo "=== Step 3: Upload to Sonatype Central Portal ===" mvn clean deploy -s "${SETTINGS_FILE}" -pl sdk -P publishing -DskipTests --no-transfer-progress +mvn clean deploy -s "${SETTINGS_FILE}" -pl dag -P publishing -DskipTests --no-transfer-progress mvn clean deploy -s "${SETTINGS_FILE}" -pl sdk-testing -P publishing -DskipTests --no-transfer-progress mvn clean deploy -s "${SETTINGS_FILE}" -pl otel-plugin -P publishing -DskipTests --no-transfer-progress diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b36580103..c03d4bf00 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,7 @@ on: - '.github/workflows/ai-pr-review.yml' - '.github/prompts/ai-pr-review.md' - 'sdk/**' + - 'dag/**' - 'sdk-testing/**' - 'sdk-integration-tests/**' - 'examples/**' @@ -38,6 +39,7 @@ on: - '.github/workflows/ai-pr-review.yml' - '.github/prompts/ai-pr-review.md' - 'sdk/**' + - 'dag/**' - 'sdk-testing/**' - 'sdk-integration-tests/**' - 'examples/**' diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b7ff944ac..717fbfd3e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -8,6 +8,7 @@ on: paths: - '.github/**' # for testing Github Actions - 'sdk/**' + - 'dag/**' - 'sdk-testing/**' - 'sdk-integration-tests/**' - 'examples/**' @@ -18,6 +19,7 @@ on: paths: - '.github/**' - 'sdk/**' + - 'dag/**' - 'sdk-testing/**' - 'sdk-integration-tests/**' - 'examples/**' diff --git a/.github/workflows/publish_maven.yml b/.github/workflows/publish_maven.yml index a664ca282..9d6c16c76 100644 --- a/.github/workflows/publish_maven.yml +++ b/.github/workflows/publish_maven.yml @@ -90,6 +90,7 @@ jobs: run: | gh release upload "$RELEASE_TAG" \ "sdk/target/aws-durable-execution-sdk-java-${RELEASE_VERSION}.jar" \ + "dag/target/aws-durable-execution-sdk-java-dag-${RELEASE_VERSION}.jar" \ "sdk-testing/target/aws-durable-execution-sdk-java-testing-${RELEASE_VERSION}.jar" \ "otel-plugin/target/aws-durable-execution-sdk-java-plugin-otel-${RELEASE_VERSION}.jar" \ --clobber diff --git a/RELEASE.md b/RELEASE.md index 90c347ff1..a7cdbc16b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -43,9 +43,9 @@ The publication workflow: 1. Verifies that the tag is a semantic version, points to a commit on the default branch, and matches the Maven version in the tagged POM. -2. Builds, signs, and uploads the SDK, testing library, and OpenTelemetry plugin - to Sonatype Central Portal. -3. Uploads the three JARs to the existing GitHub release. +2. Builds, signs, and uploads the SDK, DAG extension, testing library, and + OpenTelemetry plugin to Sonatype Central Portal. +3. Uploads the four JARs to the existing GitHub release. 4. Opens a pull request for the next development version. A final release increments the patch version, so `2.1.1` produces `2.1.2-SNAPSHOT`. A prerelease keeps the same base version, so `2.1.1-rc1` produces @@ -56,7 +56,8 @@ After **Publish Maven Release** succeeds: 1. Open [Publishing Deployments](https://central.sonatype.com/publishing/deployments) in Sonatype Central Portal. 2. Find the deployments for the release version and verify that they contain - the expected SDK, testing library, and OpenTelemetry plugin artifacts. + the expected SDK, DAG extension, testing library, and OpenTelemetry plugin + artifacts. 3. Click **Publish** for each deployment and wait for publication to complete. The workflow uses `autoPublish=false`, so this manual action is required. 4. Confirm that the GitHub release contains the expected JARs and that the diff --git a/coverage-report/pom.xml b/coverage-report/pom.xml index e820594e1..238422e45 100644 --- a/coverage-report/pom.xml +++ b/coverage-report/pom.xml @@ -22,6 +22,11 @@ aws-durable-execution-sdk-java ${project.version} + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-dag + ${project.version} + software.amazon.lambda.durable aws-durable-execution-sdk-java-testing diff --git a/dag/pom.xml b/dag/pom.xml new file mode 100644 index 000000000..6f04aa856 --- /dev/null +++ b/dag/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-parent + 2.1.1-SNAPSHOT + + + aws-durable-execution-sdk-java-dag + jar + + AWS Lambda Durable Execution SDK DAG Extension + Experimental DAG extension for the AWS Lambda Durable Execution SDK + https://github.com/aws/aws-durable-execution-sdk-java + + + scm:git:https://github.com/aws/aws-durable-execution-sdk-java.git + scm:git:https://github.com/aws/aws-durable-execution-sdk-java.git + https://github.com/aws/aws-durable-execution-sdk-java + + + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java + ${project.version} + + + + com.fasterxml.jackson.core + jackson-annotations + + + com.amazonaws + aws-lambda-java-core + + + org.slf4j + slf4j-api + + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-testing + ${project.version} + test + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + + + diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java b/dag/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java new file mode 100644 index 000000000..6a08ff937 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.Objects; +import java.util.function.Function; + +/** + * Custom-predicate DAG completion: a deterministic predicate evaluated over the DAG's live progress and task results + * after every task settlement. + * + * @param shouldComplete the predicate; receives a {@link DagCompletionStatus} snapshot of everything settled so far + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public record CustomDagCompletion(Function shouldComplete) + implements DagCompletionConfig { + public CustomDagCompletion { + Objects.requireNonNull(shouldComplete, "shouldComplete cannot be null"); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java new file mode 100644 index 000000000..0240cea61 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.StepContext; + +/** + * A DAG callback submitter: receives resolved upstream results ({@link Deps}), the generated callback ID, and a + * {@link StepContext}. Mirrors the native {@code BiConsumer} submitter shape plus {@link Deps}. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +@FunctionalInterface +public interface DagCallbackSubmitter { + void apply(Deps deps, String callbackId, StepContext ctx); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java new file mode 100644 index 000000000..2ee034cf4 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.DurableContext; + +/** + * A DAG runInChildContext task body: receives resolved upstream results ({@link Deps}) and a child + * {@link DurableContext}. + * + * @param the child context result type + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +@FunctionalInterface +public interface DagChildFunction { + T apply(Deps deps, DurableContext childCtx); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java new file mode 100644 index 000000000..43e5725bf --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.function.Function; +import software.amazon.lambda.durable.config.CompletionConfig; + +/** + * Controls when a DAG completes: threshold-based, via the six factory methods below (mirroring the base SDK's + * {@code CompletionConfig} factories), or a custom, results-aware predicate via {@link #custom(Function)}. This sealed + * interface permits {@link ThresholdDagCompletion} and {@link CustomDagCompletion}. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public sealed interface DagCompletionConfig permits ThresholdDagCompletion, CustomDagCompletion { + + /** Every task must complete; failures tolerated (captured per-task). */ + static DagCompletionConfig allCompleted() { + return new ThresholdDagCompletion(CompletionConfig.allCompleted()); + } + + /** Every task must succeed; zero failures tolerated. */ + static DagCompletionConfig allSuccessful() { + return new ThresholdDagCompletion(CompletionConfig.allSuccessful()); + } + + /** Complete as soon as the first task succeeds. */ + static DagCompletionConfig firstSuccessful() { + return new ThresholdDagCompletion(CompletionConfig.firstSuccessful()); + } + + /** Complete when {@code n} tasks have succeeded. */ + static DagCompletionConfig minSuccessful(int n) { + return new ThresholdDagCompletion(CompletionConfig.minSuccessful(n)); + } + + /** Complete when more than {@code n} failures have occurred. */ + static DagCompletionConfig toleratedFailureCount(int n) { + return new ThresholdDagCompletion(CompletionConfig.toleratedFailureCount(n)); + } + + /** Complete when the failure percentage exceeds {@code p} (0.0 to 1.0). */ + static DagCompletionConfig toleratedFailurePercentage(double p) { + return new ThresholdDagCompletion(CompletionConfig.toleratedFailurePercentage(p)); + } + + /** + * Complete based on a custom, results-aware predicate evaluated after every task settlement. + * + *

Unlike the threshold factories above, this predicate can inspect individual tasks' results (via + * {@link DagCompletionStatus#items()} / {@link DagCompletionStatus#results()}), not just aggregate counts — for + * example, stopping the moment any task's result matches a business condition. + * + * @param shouldComplete receives a live {@link DagCompletionStatus} snapshot; return + * {@link DagCompletionDecision#continueDag()} to keep scheduling or + * {@link DagCompletionDecision#complete(DagCompletionOutcome)} to stop the DAG now + */ + static DagCompletionConfig custom(Function shouldComplete) { + return new CustomDagCompletion(shouldComplete); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java new file mode 100644 index 000000000..6338d4744 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * The value a DAG custom completion predicate returns. + * + * @param complete whether the DAG should complete now + * @param outcome the completion's disposition; only meaningful when {@code complete} is {@code true}, and defaults to + * {@link DagCompletionOutcome#SUCCEEDED} via {@link #complete()} + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public record DagCompletionDecision(boolean complete, DagCompletionOutcome outcome) { + + /** Returns a decision meaning "keep scheduling ready tasks". */ + public static DagCompletionDecision continueDag() { + return new DagCompletionDecision(false, null); + } + + /** Returns a decision meaning "complete the DAG now" as a success. */ + public static DagCompletionDecision completeSuccessfully() { + return new DagCompletionDecision(true, DagCompletionOutcome.SUCCEEDED); + } + + /** Returns a decision meaning "complete the DAG now" with the given outcome. */ + public static DagCompletionDecision complete(DagCompletionOutcome outcome) { + return new DagCompletionDecision(true, outcome == null ? DagCompletionOutcome.SUCCEEDED : outcome); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java new file mode 100644 index 000000000..4f7a45796 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.Optional; + +/** + * Per-task snapshot passed to a DAG custom completion predicate. + * + * @param name the task name + * @param status the task's status; {@link Optional#empty()} if the task has not started + * @param result present only when {@code status} is {@link TaskStatus#SUCCEEDED} + * @param skipReason present only when {@code status} is {@link TaskStatus#SKIPPED} + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public record DagCompletionItemStatus( + String name, Optional status, Optional result, Optional skipReason) {} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java new file mode 100644 index 000000000..4ebf9d536 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * The terminal disposition a custom DAG completion predicate assigns to an early completion. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public enum DagCompletionOutcome { + /** Marks the early completion as a success. */ + SUCCEEDED, + /** Marks the early completion as a failure, even if no individual task failed. */ + FAILED +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java new file mode 100644 index 000000000..78beb2031 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * Why a DAG finished. A DAG-local superset of the base SDK's {@code ConcurrencyCompletionStatus} (which cannot express + * the {@link #COMPLETED_WITH_FAILURES} distinction). + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public enum DagCompletionReason { + /** Default drain: every reachable task succeeded or was skipped (no failures). */ + ALL_COMPLETED, + /** Default drain: the reachable graph fully drained but at least one task FAILED. */ + COMPLETED_WITH_FAILURES, + /** Early completion: a {@code minSuccessful} threshold was reached. */ + MIN_SUCCESSFUL_REACHED, + /** Early completion: a tolerated-failure threshold was exceeded. */ + FAILURE_TOLERANCE_EXCEEDED, + /** Early completion: a custom {@code shouldComplete} predicate completed the DAG as a success. */ + CUSTOM_COMPLETION_SUCCEEDED, + /** Early completion: a custom {@code shouldComplete} predicate completed the DAG as a failure. */ + CUSTOM_COMPLETION_FAILED +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java new file mode 100644 index 000000000..978066314 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.List; +import java.util.Map; + +/** + * Progress snapshot passed to a DAG custom completion predicate. + * + * @param successCount tasks that have succeeded so far + * @param failureCount tasks that have failed so far + * @param skippedCount tasks that have been skipped so far + * @param completedCount successCount + failureCount + skippedCount (all terminal states) + * @param totalCount total number of tasks registered in the DAG + * @param items per-task snapshot, ordered by registration order + * @param results terminal task snapshots keyed by task name + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public record DagCompletionStatus( + int successCount, + int failureCount, + int skippedCount, + int completedCount, + int totalCount, + List items, + Map results) {} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java new file mode 100644 index 000000000..92780d94c --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.StepContext; +import software.amazon.lambda.durable.model.WaitForConditionResult; + +/** + * A DAG waitForCondition check body: receives resolved upstream results ({@link Deps}), the current state, and a + * {@link StepContext}, returning a {@link WaitForConditionResult}. Mirrors the native {@code BiFunction>} shape plus {@link Deps}. + * + * @param the polled state type + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +@FunctionalInterface +public interface DagConditionFunction { + WaitForConditionResult apply(Deps deps, S state, StepContext ctx); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java new file mode 100644 index 000000000..8d1699e96 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.Optional; +import software.amazon.lambda.durable.serde.SerDes; + +/** + * Configuration for a DAG. All fields are optional. + * + *

Note: there is deliberately no {@code summaryGenerator}. The DAG container checkpoints a single SDK-owned envelope + * that is readable on its own, so no customer-supplied summary string is ever written into a payload the SDK parses + * back. Oversize aggregates degrade by dropping the per-task {@code tasks} array (its absence is the offload signal) + * while the counts, completion reason and in-flight task names always survive. + * + * @param maxConcurrency maximum number of top-level tasks running concurrently; must be {@code >= 1} if present. When + * unset, the DAG scheduler defaults to {@code 40} (previously unlimited). This bounds the DAG scheduler only — the + * top-level tasks of this DAG — and is not inherited by a task's own internal fan-out: a {@code map} or + * {@code parallel} task keeps its unlimited default unless configured, and a nested {@code dag} gets its own + * independent default of 40. An explicit value always wins, including one above the default. + * @param completionConfig early-completion policy (default: drain the whole reachable graph) + * @param defaultTriggerRule default trigger rule (default {@link TriggerRule#ALL_SUCCESS}) + * @param serDes custom serializer/deserializer for the aggregate {@link DagResult} + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public record DagConfig( + Optional maxConcurrency, + Optional completionConfig, + Optional defaultTriggerRule, + Optional serDes) { + + /** Validates invariants. */ + public DagConfig { + if (maxConcurrency.isPresent() && maxConcurrency.get() < 1) { + throw new IllegalArgumentException("maxConcurrency must be at least 1, got: " + maxConcurrency.get()); + } + } + + /** Returns a new builder. */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for {@link DagConfig}. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ + @Experimental + public static final class Builder { + private Integer maxConcurrency; + private DagCompletionConfig completionConfig; + private TriggerRule defaultTriggerRule; + private SerDes serDes; + + private Builder() {} + + public Builder maxConcurrency(Integer maxConcurrency) { + if (maxConcurrency != null && maxConcurrency < 1) { + throw new IllegalArgumentException("maxConcurrency must be at least 1, got: " + maxConcurrency); + } + this.maxConcurrency = maxConcurrency; + return this; + } + + public Builder completionConfig(DagCompletionConfig completionConfig) { + this.completionConfig = completionConfig; + return this; + } + + public Builder defaultTriggerRule(TriggerRule defaultTriggerRule) { + this.defaultTriggerRule = defaultTriggerRule; + return this; + } + + public Builder serDes(SerDes serDes) { + this.serDes = serDes; + return this; + } + + public DagConfig build() { + return new DagConfig( + Optional.ofNullable(maxConcurrency), + Optional.ofNullable(completionConfig), + Optional.ofNullable(defaultTriggerRule), + Optional.ofNullable(serDes)); + } + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagContext.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagContext.java new file mode 100644 index 000000000..c094b6117 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagContext.java @@ -0,0 +1,109 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.time.Duration; +import java.util.Collection; +import java.util.function.Consumer; +import java.util.function.Function; +import software.amazon.lambda.durable.DurableContext.MapFunction; +import software.amazon.lambda.durable.ParallelDurableFuture; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.config.InvokeConfig; +import software.amazon.lambda.durable.config.MapConfig; +import software.amazon.lambda.durable.config.ParallelConfig; +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.config.WaitForCallbackConfig; +import software.amazon.lambda.durable.config.WaitForConditionConfig; +import software.amazon.lambda.durable.model.MapResult; +import software.amazon.lambda.durable.model.ParallelResult; + +/** + * Declarative task-registration surface passed to a {@code dag(...)} registration {@code Consumer}. Each method + * registers one task and returns a {@link TaskHandle}; tasks are declared here but do not execute until registration + * returns. + * + *

Does NOT extend {@code DurableContext}: only these declarative task methods are visible during registration. + * Result typing uses the SDK's existing {@code Class}/{@code TypeToken} convention, and per-task config reuses + * the SDK's existing config types verbatim. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public interface DagContext { + + // ── step ───────────────────────────────────────────────────────────────── + TaskHandle step(String name, Class type, DagStepFunction fn); + + TaskHandle step(String name, TypeToken type, DagStepFunction fn); + + TaskHandle step(String name, Class type, DagStepFunction fn, StepConfig config); + + TaskHandle step(String name, TypeToken type, DagStepFunction fn, StepConfig config); + + // ── step: positional-arity typed-deps sugar (§2.7) ───────────────────────── + // Compile-time-checked convenience overloads for the common 1..3 typed-dep case: each upstream result is passed to + // the body directly (typed via the handle's generic), desugaring to step(...).reads(...) + Deps.get(...). For >3 + // deps or ordering-only edges, use the canonical step(...) + .reads(...)/.after(...) + Deps.get(...) form. + TaskHandle step(String name, Class type, TaskHandle a, DagStep1Function fn); + + TaskHandle step( + String name, Class type, TaskHandle a, TaskHandle b, DagStep2Function fn); + + TaskHandle step( + String name, + Class type, + TaskHandle a, + TaskHandle b, + TaskHandle c, + DagStep3Function fn); + + // ── invoke ─────────────────────────────────────────────────────────────── + TaskHandle invoke(String name, String functionName, Class type, DagPayloadFunction payloadFn); + + TaskHandle invoke( + String name, String functionName, Class type, DagPayloadFunction payloadFn, InvokeConfig config); + + // ── callback ───────────────────────────────────────────────────────────── + TaskHandle callback(String name, Class type, DagCallbackSubmitter submitter); + + TaskHandle callback( + String name, Class type, DagCallbackSubmitter submitter, WaitForCallbackConfig config); + + // ── wait ───────────────────────────────────────────────────────────────── + TaskHandle wait(String name, Duration duration); + + // ── waitForCondition ────────────────────────────────────────────────────── + TaskHandle waitForCondition( + String name, Class type, DagConditionFunction check, WaitForConditionConfig config); + + // ── runInChildContext ───────────────────────────────────────────────────── + TaskHandle runInChildContext(String name, Class type, DagChildFunction fn); + + TaskHandle runInChildContext(String name, TypeToken type, DagChildFunction fn); + + // ── map ────────────────────────────────────────────────────────────────── + TaskHandle> map(String name, Collection items, Class type, MapFunction fn); + + TaskHandle> map( + String name, Collection items, Class type, MapFunction fn, MapConfig config); + + TaskHandle> map( + String name, Function> items, Class type, MapFunction fn); + + TaskHandle> map( + String name, Function> items, Class type, MapFunction fn, MapConfig config); + + // ── parallel ────────────────────────────────────────────────────────────── + // NOTE: branches are declared against the SDK's existing ParallelDurableFuture (reused verbatim, no new + // ParallelBuilder type) — the scheduler applies the consumer to the launched parallel future. + TaskHandle parallel(String name, Consumer branches); + + TaskHandle parallel(String name, Consumer branches, ParallelConfig config); + + // ── nested dag ──────────────────────────────────────────────────────────── + TaskHandle dag(String name, Consumer register); + + TaskHandle dag(String name, Consumer register, DagConfig config); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java new file mode 100644 index 000000000..7a342a426 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * Thrown at registration when the declared task dependencies form a cycle. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagCyclicDependencyException extends DagException { + public DagCyclicDependencyException(String message) { + super(message); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java new file mode 100644 index 000000000..da2b9ad11 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * Thrown at registration when two tasks are registered under the same name in the same DAG scope. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagDuplicateTaskException extends DagException { + public DagDuplicateTaskException(String message) { + super(message); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagException.java new file mode 100644 index 000000000..c68809aae --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagException.java @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.exception.DurableExecutionException; + +/** + * Base exception for DAG operations. DAG failures are extension-level errors rather than failures associated with one + * primitive operation. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagException extends DurableExecutionException { + public DagException(String message) { + super(message); + } + + public DagException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java new file mode 100644 index 000000000..76f00a735 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Thrown by {@code DagResult.throwIfError()} when the DAG completed with at least one failed task. Wraps the first + * failed task's cause (when available). + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagExecutionException extends DagException { + public DagExecutionException(String message) { + super(message); + } + + /** + * Primary constructor: wraps the first failed task's cause. + * + *

Annotated as the {@code @JsonCreator} so that when this exception crosses a {@code runInChildContext} boundary + * (a nested DAG task that calls {@code throwIfError()} in its body) it is reconstructed with its cause set at + * construction time. This avoids {@code Throwable.initCause}, which the base hierarchy would otherwise reject + * because it pre-initializes the cause to {@code null} — the same idiom {@link DagPredicateException} uses. Without + * it, a cause-carrying {@code DagExecutionException} degrades to a bare {@code ChildContextFailedException} at the + * caller. Jackson passes {@code cause == null} for the no-cause form, which is equivalent to the single-arg + * constructor. + */ + @JsonCreator + public DagExecutionException(@JsonProperty("message") String message, @JsonProperty("cause") Throwable cause) { + super(message, cause); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java new file mode 100644 index 000000000..992d99d0a --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * Thrown at registration when a task declares a dependency on a handle that was not registered in the same DAG scope. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagInvalidDependencyException extends DagException { + public DagInvalidDependencyException(String message) { + super(message); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java new file mode 100644 index 000000000..065ca1c55 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * Thrown at registration when a task name violates the DAG charset rules ({@code ^[a-zA-Z0-9_]+$}, {@code <= 100} + * chars, must not contain the reserved sequence {@code DAG_NODE_T_}). + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagInvalidTaskNameException extends DagException { + public DagInvalidTaskNameException(String message) { + super(message); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java new file mode 100644 index 000000000..0558bceeb --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * Produces the payload for a DAG invoke task from resolved upstream results ({@link Deps}). + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +@FunctionalInterface +public interface DagPayloadFunction { + Object apply(Deps deps); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java new file mode 100644 index 000000000..647cf21fd --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Thrown when a task's {@code runIf} predicate throws. A {@code runIf} predicate is specified as a synchronous, + * deterministic, pure function of resolved upstream results, re-evaluated on every replay and never checkpointed. A + * throw is therefore a defect in deterministic code, not a business outcome: the scheduler aborts the DAG + * (it starts no further tasks and the offending task is left with no terminal state — neither {@code FAILED} nor + * {@code SKIPPED}) and the {@code dag(...)} operation fails with this exception rather than recording a task failure + * that would drive {@code ALL_FAILED}/{@code ANY_FAILED}/{@code ALL_DONE} compensation paths. + * + *

The {@linkplain #getMessage() message} names the offending task and the {@linkplain #getCause() cause} is the + * original error thrown by the predicate, with its stack trace preserved. + * + *

Child-context boundary. A DAG runs inside a {@code runInChildContext} node. When this exception crosses + * that boundary it is checkpointed and reconstructed from its serialized form; the {@code dag(...)} caller observes a + * {@code DagPredicateException} whose message names the task, whose {@link #taskName()} is preserved, and whose cause + * is the reconstructed original error. See {@code docs/core/dag.md}. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagPredicateException extends DagException { + + private final String taskName; + + /** + * Primary constructor used by the scheduler. + * + * @param taskName the name of the task whose {@code runIf} predicate threw + * @param cause the original error thrown by the predicate + */ + public DagPredicateException(String taskName, Throwable cause) { + super(buildMessage(taskName, cause), cause); + this.taskName = taskName; + } + + /** + * Reconstruction constructor used when the exception is deserialized after crossing the child-context boundary. It + * takes the already-built message and the reconstructed cause directly (setting the cause at construction avoids + * {@code Throwable.initCause}, which the base hierarchy would otherwise reject because it pre-initializes the cause + * to {@code null}). Kept private; selected by Jackson via {@code @JsonCreator}. + */ + @JsonCreator + private DagPredicateException( + @JsonProperty("message") String message, + @JsonProperty("cause") Throwable cause, + @JsonProperty("taskName") String taskName) { + super(message, cause); + this.taskName = taskName; + } + + /** The name of the task whose {@code runIf} predicate threw. */ + @JsonProperty("taskName") + public String taskName() { + return taskName; + } + + private static String buildMessage(String taskName, Throwable cause) { + StringBuilder sb = new StringBuilder("runIf predicate for DAG task '") + .append(taskName) + .append("' threw "); + if (cause == null) { + sb.append("null"); + } else { + sb.append(cause.getClass().getName()); + if (cause.getMessage() != null) { + sb.append(": ").append(cause.getMessage()); + } + } + return sb.toString(); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagResult.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagResult.java new file mode 100644 index 000000000..4fb829de9 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagResult.java @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Aggregate result of a completed DAG. Provides typed and untyped accessors for individual task results and statuses, + * grouped views, counts, the completion reason, and a fail-fast helper. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public interface DagResult { + + /** Typed result of a task by handle. Empty if the task was skipped, never started, or did not succeed. */ + Optional getResult(TaskHandle handle); + + /** Untyped result of a task by name. */ + Optional getResult(String name); + + /** Terminal status of a task by handle. */ + Optional getStatus(TaskHandle handle); + + /** Terminal status of a task by name. */ + Optional getStatus(String name); + + /** All succeeded task executions. */ + List> succeeded(); + + /** All failed task executions. */ + List> failed(); + + /** All skipped task executions. */ + List> skipped(); + + /** All task executions keyed by name (unmodifiable). */ + Map> results(); + + int successCount(); + + int failureCount(); + + int skippedCount(); + + /** + * Number of registered tasks. Fixed at registration; independent of early completion and never-started tasks (spec + * §2.8). Never-started tasks are absent from {@link #results()} — use {@link #getStatus(String)} to disambiguate. + */ + int totalCount(); + + /** Why the DAG finished. */ + DagCompletionReason completionReason(); + + /** + * Names of tasks that were launched but had not reached a terminal state when the DAG stopped early (bounded by + * {@code maxConcurrency}). Empty when the DAG drained fully. These tasks are deliberately excluded from + * {@link #results()}; this set is what a large-payload reconstruct preserves that no child operation records. + */ + List startedTaskNames(); + + /** Names of the {@link #failed()} tasks, in registration order. */ + default List failedTaskNames() { + return failed().stream().map(TaskExecution::name).toList(); + } + + /** Throws {@link DagExecutionException} if {@link #failureCount()} {@code > 0}. */ + void throwIfError(); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java new file mode 100644 index 000000000..33d788eb8 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.StepContext; + +/** + * A DAG step body for the positional-arity sugar with a single typed upstream dependency: receives the resolved result + * of dependency {@code A} directly (instead of a {@link Deps} accessor) plus a {@link StepContext}. Desugars to + * {@code step(...).reads(a)} + {@link Deps#get}. + * + * @param the upstream dependency's result type + * @param the step result type + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +@FunctionalInterface +public interface DagStep1Function { + T apply(A a, StepContext ctx); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java new file mode 100644 index 000000000..41f724f4c --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.StepContext; + +/** + * A DAG step body for the positional-arity sugar with two typed upstream dependencies: receives the resolved results of + * dependencies {@code A} and {@code B} directly (instead of a {@link Deps} accessor) plus a {@link StepContext}. + * Desugars to {@code step(...).reads(a, b)} + {@link Deps#get}. + * + * @param the first upstream dependency's result type + * @param the second upstream dependency's result type + * @param the step result type + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +@FunctionalInterface +public interface DagStep2Function { + T apply(A a, B b, StepContext ctx); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java new file mode 100644 index 000000000..4211cf198 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.StepContext; + +/** + * A DAG step body for the positional-arity sugar with three typed upstream dependencies: receives the resolved results + * of dependencies {@code A}, {@code B} and {@code C} directly (instead of a {@link Deps} accessor) plus a + * {@link StepContext}. Desugars to {@code step(...).reads(a, b, c)} + {@link Deps#get}. + * + * @param the first upstream dependency's result type + * @param the second upstream dependency's result type + * @param the third upstream dependency's result type + * @param the step result type + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +@FunctionalInterface +public interface DagStep3Function { + T apply(A a, B b, C c, StepContext ctx); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java new file mode 100644 index 000000000..7ddbd0790 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.StepContext; + +/** + * A DAG step task body: receives resolved upstream results ({@link Deps}, empty for roots) and a {@link StepContext}. + * + * @param the step result type + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +@FunctionalInterface +public interface DagStepFunction { + T apply(Deps deps, StepContext ctx); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java new file mode 100644 index 000000000..11db4a36e --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Optional; +import software.amazon.lambda.durable.util.ExceptionHelper; + +/** + * Error details for a failed DAG task. + * + *

Serializes to the cross-language canonical error object shape — PascalCase {@code ErrorType} / + * {@code ErrorMessage} / {@code StackTrace} (envelope convergence contract), matching the platform's error-object + * convention and what the JS and Python SDKs already emit. {@code StackTrace} is {@code null} when unavailable. Carries + * an optional reconstructed {@code cause} which is never serialized. + * + *

Value semantics (recorded cross-language difference): Java's {@code ErrorType} carries the thrown + * exception's class name (e.g. {@code java.lang.RuntimeException}) — see {@link #of(Throwable)} — whereas JS and + * Python put the SDK operation error type (e.g. {@code StepError}). Field names converge; these values are + * language-specific by nature and are documented, not converged, in this change. + * + * @param errorType the thrown exception's fully qualified class name (serialized as {@code ErrorType}) + * @param errorMessage the error message (serialized as {@code ErrorMessage}) + * @param stackTrace the stack trace frames, or {@code null} (serialized as {@code StackTrace}) + * @param cause the reconstructed cause, if available (never serialized) + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public record DagTaskError( + @JsonProperty("ErrorType") String errorType, + @JsonProperty("ErrorMessage") String errorMessage, + @JsonProperty("StackTrace") List stackTrace, + @JsonIgnore Optional cause) { + + /** Jackson entry point — reconstructs without a cause, from the PascalCase canonical shape. */ + @JsonCreator + public DagTaskError( + @JsonProperty("ErrorType") String errorType, + @JsonProperty("ErrorMessage") String errorMessage, + @JsonProperty("StackTrace") List stackTrace) { + this(errorType, errorMessage, stackTrace, Optional.empty()); + } + + /** Builds a {@code DagTaskError} from a throwable, retaining it as the (non-serialized) cause. */ + public static DagTaskError of(Throwable e) { + return new DagTaskError( + e.getClass().getName(), + e.getMessage(), + ExceptionHelper.serializeStackTrace(e.getStackTrace()), + Optional.of(e)); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/Deps.java b/dag/src/main/java/software/amazon/lambda/durable/dag/Deps.java new file mode 100644 index 000000000..462fbcf9a --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/Deps.java @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.Optional; + +/** + * Typed accessor for the results of a DAG task's upstream (inline) dependencies. Passed as the first parameter of every + * DAG task function. + * + *

This is Java's answer to the JS {@code DepsMap}: instead of literal-string type keys, a result is retrieved by + * passing the upstream task's {@link TaskHandle}, which carries the result type via generics. Only handles declared via + * {@code TaskHandle.reads(...)} are retrievable; ordering-only dependencies (declared via {@code after(...)}) are not. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public interface Deps { + + /** + * Returns the checkpointed result of an upstream inline dependency as an {@link Optional}. + * + *

The result is {@link Optional#empty()} whenever the upstream did not produce a success value — i.e. it FAILED + * or was SKIPPED. This is possible under non-ALL_SUCCESS trigger rules ({@code ALL_DONE}, {@code ANY_FAILED}, + * {@code NONE_FAILED}, {@code ALL_FAILED}), where a task can run even though one of its inline dependencies did not + * succeed. For the default {@code ALL_SUCCESS} trigger rule the value is always present, so callers may unwrap with + * {@link Optional#orElseThrow()}. + * + * @param the upstream task's result type + * @param handle the upstream task's handle (must be an inline dependency declared via {@code reads(...)}) + * @return the upstream result, or {@link Optional#empty()} if the upstream did not SUCCEED + * @throws IllegalStateException if {@code handle} was not declared as an inline dependency of this task + */ + Optional get(TaskHandle handle); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/DurableDagOperation.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DurableDagOperation.java new file mode 100644 index 000000000..d5f27d6e0 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DurableDagOperation.java @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.Objects; +import java.util.function.Consumer; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.dag.internal.DagContextImpl; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.util.ParameterValidator; + +/** Context-free static facade and canonical implementation of experimental durable DAG operations. */ +@Experimental +public final class DurableDagOperation { + private DurableDagOperation() {} + + /** Declares and runs a DAG using the current durable extension context. */ + public static DagResult dag(String name, Consumer register) { + return dagAsync(name, register).get(); + } + + /** Declares and runs a configured DAG using the current durable extension context. */ + public static DagResult dag(String name, Consumer register, DagConfig config) { + return dagAsync(name, register, config).get(); + } + + /** Asynchronously declares and runs a DAG using the current durable extension context. */ + public static DurableFuture dagAsync(String name, Consumer register) { + return dagAsync(name, register, DagConfig.builder().build()); + } + + /** Asynchronously declares and runs a configured DAG using the current durable extension context. */ + public static DurableFuture dagAsync(String name, Consumer register, DagConfig config) { + Objects.requireNonNull(register, "register cannot be null"); + Objects.requireNonNull(config, "config cannot be null"); + ParameterValidator.validateOperationName(name); + + var context = ExtensionContext.getCurrentContext(); + var dagContext = DagContextImpl.registerAndValidate(register); + return DagContextImpl.start(context, context.reserve(name), dagContext, config); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/Experimental.java b/dag/src/main/java/software/amazon/lambda/durable/dag/Experimental.java new file mode 100644 index 000000000..2c49d6dcb --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/Experimental.java @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a public API as experimental. + * + *

Experimental APIs may be changed or removed in future releases without a major-version bump and without + * prior notice. They are provided for early evaluation and feedback. Do not depend on experimental APIs in production + * code until they are promoted to stable. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Documented +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface Experimental {} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java b/dag/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java new file mode 100644 index 000000000..71be3ef8d --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * Explains why a DAG task was {@link TaskStatus#SKIPPED}. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public enum SkipReason { + /** The task's {@link TriggerRule} was not satisfied by its upstream dependencies' terminal statuses. */ + TRIGGER_RULE, + /** The task's {@code runIf} predicate evaluated to {@code false}. */ + RUN_IF_PREDICATE +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java new file mode 100644 index 000000000..5d286d920 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.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.dag; + +import java.time.Instant; +import java.util.Optional; + +/** + * The recorded outcome of a single DAG task. + * + * @param the task result type + * @param name the task name + * @param status the terminal status + * @param skipReason present only when {@code status == SKIPPED} + * @param result present only when {@code status == SUCCEEDED} + * @param error present only when {@code status == FAILED} + * @param startedAt when the task started, when available; currently empty because the extension SPI does not expose + * operation timestamps + * @param completedAt when the task completed, when available; currently empty because the extension SPI does not expose + * operation timestamps + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public record TaskExecution( + String name, + TaskStatus status, + Optional skipReason, + Optional result, + Optional error, + Optional startedAt, + Optional completedAt) {} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java new file mode 100644 index 000000000..53c7f1a04 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.function.Predicate; + +/** + * A registration-time reference to a DAG task, carrying the task's result type {@code T} via generics. Returned by each + * {@link DagContext} registration method and used to declare dependencies and retrieve results via {@link Deps}. + * + *

Builder methods return {@code this} for fluent chaining. The handle's in-memory identity (not its name) is the key + * used by the scheduler; it is never serialized. + * + * @param the task's result type + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public interface TaskHandle { + + /** The task name (runtime string; not a type-level literal). */ + String name(); + + /** + * Declares inline (typed) dependencies: the task waits for these AND can retrieve their results via + * {@link Deps#get}. Only handles declared here are retrievable inside the task function. + * + * @param deps the upstream handles to read + * @return this handle, for chaining + */ + TaskHandle reads(TaskHandle... deps); + + /** + * Declares ordering-only dependencies: the task waits for these but does NOT receive their results in {@link Deps}. + * + * @param deps the upstream handles to wait for + * @return this handle, for chaining + */ + TaskHandle after(TaskHandle... deps); + + /** + * Sets the trigger rule (defaults to {@code DagConfig.defaultTriggerRule}, else {@link TriggerRule#ALL_SUCCESS}). + * + * @param rule the trigger rule + * @return this handle, for chaining + */ + TaskHandle triggerRule(TriggerRule rule); + + /** + * Sets a conditional-skip predicate evaluated (after the trigger rule passes) over resolved upstream results. When + * it returns {@code false} the task is {@link TaskStatus#SKIPPED} with {@link SkipReason#RUN_IF_PREDICATE}. + * + * @param predicate the run-if predicate + * @return this handle, for chaining + */ + TaskHandle runIf(Predicate predicate); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java new file mode 100644 index 000000000..67face681 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * Terminal (or in-progress) status of a single DAG task. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public enum TaskStatus { + /** The task ran and completed successfully. */ + SUCCEEDED, + /** The task ran and threw. A failure is a terminal task state, not an abort of the DAG. */ + FAILED, + /** The task did not run because its trigger rule or {@code runIf} predicate was not satisfied. */ + SKIPPED, + /** The task has been launched but has not yet reached a terminal state. */ + STARTED +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java b/dag/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java new file mode 100644 index 000000000..b1f1b981a --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.config.CompletionConfig; + +/** + * Threshold-based DAG completion, wrapping the base SDK's {@link CompletionConfig}. + * + * @param completionConfig the underlying threshold configuration + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public record ThresholdDagCompletion(CompletionConfig completionConfig) implements DagCompletionConfig {} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java b/dag/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java new file mode 100644 index 000000000..76d854104 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +/** + * Determines whether a DAG task runs based on the terminal statuses of its upstream dependencies. + * + *

A pure value type: the rule's truth function (including the empty-upstream/vacuous case for root tasks or tasks + * with no dependencies) is evaluated internally by the scheduler. The default rule is {@link #ALL_SUCCESS}. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public enum TriggerRule { + /** Run only if every upstream SUCCEEDED. Empty upstream: run (vacuously true). */ + ALL_SUCCESS, + /** Run only if every upstream FAILED. Empty upstream: skip. */ + ALL_FAILED, + /** Run once every upstream is terminal, regardless of outcome. Empty upstream: run. */ + ALL_DONE, + /** Run if at least one upstream SUCCEEDED. Empty upstream: skip. */ + ANY_SUCCESS, + /** Run if at least one upstream FAILED. Empty upstream: skip. */ + ANY_FAILED, + /** Run if no upstream FAILED (SUCCEEDED and SKIPPED are fine). Empty upstream: run. */ + NONE_FAILED +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java new file mode 100644 index 000000000..d88a8a3d3 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java @@ -0,0 +1,407 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableContext.MapFunction; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.ParallelDurableFuture; +import software.amazon.lambda.durable.StepContext; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.config.InvokeConfig; +import software.amazon.lambda.durable.config.MapConfig; +import software.amazon.lambda.durable.config.ParallelConfig; +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.config.StepSemantics; +import software.amazon.lambda.durable.config.WaitForCallbackConfig; +import software.amazon.lambda.durable.config.WaitForConditionConfig; +import software.amazon.lambda.durable.dag.DagCallbackSubmitter; +import software.amazon.lambda.durable.dag.DagChildFunction; +import software.amazon.lambda.durable.dag.DagConditionFunction; +import software.amazon.lambda.durable.dag.DagConfig; +import software.amazon.lambda.durable.dag.DagContext; +import software.amazon.lambda.durable.dag.DagPayloadFunction; +import software.amazon.lambda.durable.dag.DagResult; +import software.amazon.lambda.durable.dag.DagStep1Function; +import software.amazon.lambda.durable.dag.DagStep2Function; +import software.amazon.lambda.durable.dag.DagStep3Function; +import software.amazon.lambda.durable.dag.DagStepFunction; +import software.amazon.lambda.durable.dag.Deps; +import software.amazon.lambda.durable.dag.TaskHandle; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.extension.ExtensionContextConfig; +import software.amazon.lambda.durable.extension.ExtensionContextResult; +import software.amazon.lambda.durable.extension.ExtensionInvokeConfig; +import software.amazon.lambda.durable.extension.ExtensionOperation; +import software.amazon.lambda.durable.extension.ExtensionStepConfig; +import software.amazon.lambda.durable.extension.ExtensionStepResult; +import software.amazon.lambda.durable.model.MapResult; +import software.amazon.lambda.durable.model.ParallelResult; +import software.amazon.lambda.durable.operation.DurableMapOperation; +import software.amazon.lambda.durable.operation.DurableParallelOperation; +import software.amazon.lambda.durable.operation.DurableWaitForCallbackOperation; +import software.amazon.lambda.durable.operation.DurableWaitForConditionOperation; +import software.amazon.lambda.durable.retry.RetryStrategy; +import software.amazon.lambda.durable.serde.SerDes; + +/** + * Concrete {@link DagContext}. Each registration method records a {@link TaskHandleImpl} whose executor launches the + * underlying operation through a stable public extension-operation reservation. + */ +public final class DagContextImpl implements DagContext { + private static final int LARGE_RESULT_THRESHOLD = 256 * 1024; + private static final String CALLBACK_SUBTYPE = "Callback"; + private static final String DAG_SUBTYPE = "Dag"; + private static final String INVOKE_SUBTYPE = "ChainedInvoke"; + private static final String RUN_IN_CHILD_CONTEXT_SUBTYPE = "RunInChildContext"; + private static final String STEP_SUBTYPE = "Step"; + private static final String WAIT_SUBTYPE = "Wait"; + + private final List> tasks = new ArrayList<>(); + + /** + * Declared result types of this scope's tasks, keyed by task name — the graph that lets {@link DagResultSerDes} + * recover a PLAIN result's type on replay from the registered task (by name) instead of from a class name persisted + * in the checkpoint. Populated during registration; nested DAGs contribute their own graph recursively (see + * {@link #collectResultTypes}). + */ + private final Map> declaredResultTypes = new LinkedHashMap<>(); + + /** Nested DAG contexts registered in this scope, keyed by their task name, for recursive type-graph assembly. */ + private final Map nestedContexts = new LinkedHashMap<>(); + + public List> tasks() { + return tasks; + } + + /** Records a task's declared result type by name for PLAIN-result type recovery on replay. Generics welcome. */ + private void recordResultType(String name, TypeToken type) { + declaredResultTypes.put(name, type); + } + + /** + * The transitive declared-result-type graph for this DAG and all nested DAGs, keyed by task name. Handed to + * {@link DagResultSerDes} so checkpoint replay can reconstruct a PLAIN result into the type the task was declared + * with — recovered by name from the registered graph, never from an untrusted checkpoint-stored class name. + */ + public DagResultTypes collectResultTypes() { + Map nested = new LinkedHashMap<>(); + for (var e : nestedContexts.entrySet()) { + nested.put(e.getKey(), e.getValue().collectResultTypes()); + } + return new DagResultTypes(declaredResultTypes, nested); + } + + private TaskHandle register(TaskHandleImpl handle) { + tasks.add(handle); + return handle; + } + + /** + * Runs the declarative registration phase and validates the resulting graph, returning the populated context. + * + *

Failures are terminal task states, not aborts: by default the scheduler drains the reachable graph so compensation + * tasks (ALL_FAILED / ALL_DONE) run. Skips (trigger-rule / runIf) are terminal, cascade downstream, and checkpoint + * nothing. + */ +public final class DagExecutor { + + /** Prefix applied to task names before minting name-based operation IDs. */ + public static final String NODE_PREFIX = "DAG_NODE_T_"; + + /** + * Default cap on the number of top-level tasks a DAG runs concurrently when {@link DagConfig#maxConcurrency()} is + * unset. Bounds the DAG scheduler only (one level, top-level tasks); it is not inherited by a task's own internal + * fan-out (a {@code map}/{@code parallel} task keeps its unlimited default, and a nested {@code dag} gets its own + * independent default of 40). An explicit {@code maxConcurrency} always wins, including values above 40. See the + * cross-SDK default-concurrency contract (review finding H2). + */ + public static final int DEFAULT_MAX_CONCURRENCY = 40; + + private DagExecutor() {} + + /** + * Runs the scheduler to completion (may suspend/replay). + * + * @param tasks the registered tasks, in registration order (already validated) + * @param childCtx the DAG child context to launch operations in + * @param config the DAG configuration + * @return the terminal outcome + */ + public static DagExecutionOutcome run(List> tasks, ExtensionContext childCtx, DagConfig config) { + + int maxConcurrency = config.maxConcurrency().orElse(DEFAULT_MAX_CONCURRENCY); + TriggerRule defaultRule = config.defaultTriggerRule().orElse(TriggerRule.ALL_SUCCESS); + Optional completion = config.completionConfig(); + int totalTaskCount = tasks.size(); + + Map> results = new LinkedHashMap<>(); + Map operations = reserveOperations(tasks, childCtx); + // name -> (task, future); insertion order = launch order + LinkedHashMap inFlight = new LinkedHashMap<>(); + + DagCompletionReason earlyReason = null; + List startedTaskNames = new ArrayList<>(); + + fillReady(tasks, childCtx, operations, defaultRule, maxConcurrency, results, inFlight); + + while (!inFlight.isEmpty()) { + var it = inFlight.entrySet().iterator(); + var entry = it.next(); + it.remove(); + String name = entry.getKey(); + try { + Object result = entry.getValue().future.get(); + results.put(name, succeeded(name, result)); + } catch (UnrecoverableDurableExecutionException e) { + throw e; + } catch (RuntimeException e) { + results.put(name, failed(name, DagTaskError.of(e))); + } + + var reason = evaluateEarlyCompletion(completion, tasks, results, totalTaskCount); + if (reason != null) { + earlyReason = reason; + // Early completion: capture the tasks that were launched but had not reached a terminal state at the + // deterministic stop point (in launch order). These are excluded from `results`, but the envelope + // records them as startedTaskNames — the started set that no child operation records, and which a + // large-payload reconstruct must preserve. On replay the scheduler re-evaluates completion + // deterministically, reaches the identical stop point, and reproduces this exact set. + startedTaskNames = new ArrayList<>(inFlight.keySet()); + // Stop launching/awaiting and abandon any still-in-flight tasks. This is deliberate and replay-safe: + // each in-flight op was launched under its name-based ID + // (idOf(name)), so any late checkpoint it writes is inert on replay — the scheduler + // re-evaluates completion deterministically, reaches the identical stop point, and never + // reads a checkpoint for a task past that point (spec §8.1(3)). Abandoned tasks are therefore + // excluded from `results`, preserving the minimal-set early-completion semantics. We drop the + // in-flight references so no further waves are launched. + inFlight.clear(); + break; + } + fillReady(tasks, childCtx, operations, defaultRule, maxConcurrency, results, inFlight); + } + + DagCompletionReason completionReason; + if (earlyReason != null) { + completionReason = earlyReason; + } else { + completionReason = countByStatus(results, TaskStatus.FAILED) > 0 + ? DagCompletionReason.COMPLETED_WITH_FAILURES + : DagCompletionReason.ALL_COMPLETED; + } + + // Rebuild in registration order for deterministic output. + Map> ordered = new LinkedHashMap<>(); + for (var task : tasks) { + var exec = results.get(task.name()); + if (exec != null) { + ordered.put(task.name(), exec); + } + } + return new DagExecutionOutcome(ordered, startedTaskNames, completionReason, totalTaskCount); + } + + private static Map reserveOperations( + List> tasks, ExtensionContext context) { + Map operations = new HashMap<>(); + for (var task : tasks) { + operations.put(task.name(), context.reserve(task.name(), NODE_PREFIX + task.name())); + } + return operations; + } + + /** Launches/skips every currently-ready task, up to the concurrency cap. Idempotent within a wave. */ + private static void fillReady( + List> tasks, + ExtensionContext childCtx, + Map operations, + TriggerRule defaultRule, + int maxConcurrency, + Map> results, + LinkedHashMap inFlight) { + boolean changed = true; + while (changed) { + changed = false; + for (var task : tasks) { + String name = task.name(); + if (results.containsKey(name) || inFlight.containsKey(name)) { + continue; + } + if (!depsTerminal(task, results)) { + continue; + } + var statuses = depStatuses(task, results); + var rule = task.triggerRuleOpt().orElse(defaultRule); + if (!TriggerRuleEvaluator.eval(rule, statuses)) { + results.put(name, skipped(name, SkipReason.TRIGGER_RULE)); + changed = true; + continue; + } + // B1 fix: snapshot THIS task's inline deps into an immutable map and hand that to DepsImpl instead of + // the scheduler's live `results` map. Every inline dep is already terminal here (depsTerminal(task, + // results) passed above), so the snapshot is complete and its values never change. This removes the + // shared mutable state entirely: task bodies (which call deps.get(...) on user-executor threads) read + // their own private, immutable view rather than racing the scheduler thread's results.put(...) writes + // on a non-thread-safe LinkedHashMap. Deps semantics are unchanged (requireDeclared + SUCCEEDED-only). + Map> depsSnapshot = new HashMap<>(); + for (TaskHandle dep : task.inlineDeps()) { + depsSnapshot.put(dep.name(), results.get(dep.name())); + } + Deps deps = new DepsImpl(task.name(), task.inlineDeps(), Collections.unmodifiableMap(depsSnapshot)); + if (task.runIfOpt().isPresent()) { + boolean run; + try { + run = task.runIfOpt().get().test(deps); + } catch (RuntimeException t) { + // A runIf predicate is specified as synchronous/deterministic/pure; a throw is a defect in + // deterministic code, NOT a business outcome. Abort the DAG with a typed error instead of + // recording the task FAILED (which would fire ALL_FAILED/ANY_FAILED/ALL_DONE compensation) or + // SKIPPED. The offending task gets no terminal state and no further tasks are launched; the + // throw escapes the scheduler and fails the DAG child-context body. See DagPredicateException. + throw new DagPredicateException(name, t); + } + if (!run) { + results.put(name, skipped(name, SkipReason.RUN_IF_PREDICATE)); + changed = true; + continue; + } + } + if (inFlight.size() < maxConcurrency) { + DurableFuture future = launch(task, childCtx, operations.get(name), deps); + inFlight.put(name, new InFlight(future)); + changed = true; + } + } + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static DurableFuture launch( + TaskHandleImpl task, ExtensionContext ctx, ExtensionOperation operation, Deps deps) { + TaskExecutor executor = task.executor(); + return executor.launch(ctx, operation, deps); + } + + private static boolean depsTerminal(TaskHandleImpl task, Map> results) { + for (TaskHandle dep : task.allDeps()) { + if (!results.containsKey(dep.name())) { + return false; + } + } + return true; + } + + private static List depStatuses(TaskHandleImpl task, Map> results) { + List statuses = new ArrayList<>(); + for (TaskHandle dep : task.allDeps()) { + statuses.add(results.get(dep.name()).status()); + } + return statuses; + } + + private static DagCompletionReason evaluateEarlyCompletion( + Optional completion, + List> tasks, + Map> results, + int totalTaskCount) { + if (completion.isEmpty()) { + return null; // default: drain the whole reachable graph + } + var dcc = completion.get(); + if (dcc instanceof CustomDagCompletion custom) { + DagCompletionStatus status = buildCompletionStatus(tasks, results, totalTaskCount); + DagCompletionDecision decision = custom.shouldComplete().apply(status); + if (decision.complete()) { + return decision.outcome() == DagCompletionOutcome.FAILED + ? DagCompletionReason.CUSTOM_COMPLETION_FAILED + : DagCompletionReason.CUSTOM_COMPLETION_SUCCEEDED; + } + return null; + } + var cc = unwrap(dcc); + int succeeded = countByStatus(results, TaskStatus.SUCCEEDED); + int failed = countByStatus(results, TaskStatus.FAILED); + if (cc.minSuccessful() != null && succeeded >= cc.minSuccessful()) { + return DagCompletionReason.MIN_SUCCESSFUL_REACHED; + } + if (cc.toleratedFailureCount() != null && failed > cc.toleratedFailureCount()) { + return DagCompletionReason.FAILURE_TOLERANCE_EXCEEDED; + } + if (cc.toleratedFailurePercentage() != null + && totalTaskCount > 0 + && (double) failed / totalTaskCount > cc.toleratedFailurePercentage()) { + return DagCompletionReason.FAILURE_TOLERANCE_EXCEEDED; + } + return null; + } + + /** + * Builds the live progress snapshot passed to a custom {@code shouldComplete} predicate: every task in registration + * order, keyed by name, reflecting exactly what has settled so far (tasks with no entry in {@code results} yet are + * reported with an empty status, i.e. not yet started). + */ + private static DagCompletionStatus buildCompletionStatus( + List> tasks, Map> results, int totalTaskCount) { + List items = new ArrayList<>(tasks.size()); + Map byName = new LinkedHashMap<>(); + int succeeded = 0; + int failed = 0; + int skipped = 0; + for (TaskHandleImpl task : tasks) { + TaskExecution exec = results.get(task.name()); + DagCompletionItemStatus item; + if (exec == null) { + item = new DagCompletionItemStatus(task.name(), Optional.empty(), Optional.empty(), Optional.empty()); + } else { + item = new DagCompletionItemStatus( + task.name(), + Optional.of(exec.status()), + Optional.ofNullable(exec.result().orElse(null)), + exec.skipReason()); + switch (exec.status()) { + case SUCCEEDED -> succeeded++; + case FAILED -> failed++; + case SKIPPED -> skipped++; + default -> { + // STARTED tasks are never present in `results` (only terminal states are recorded there), + // so this branch is unreachable; kept for exhaustiveness. + } + } + } + items.add(item); + byName.put(task.name(), item); + } + int completedCount = succeeded + failed + skipped; + return new DagCompletionStatus(succeeded, failed, skipped, completedCount, totalTaskCount, items, byName); + } + + private static CompletionConfig unwrap(DagCompletionConfig dcc) { + if (dcc instanceof ThresholdDagCompletion threshold) { + return threshold.completionConfig(); + } + throw new IllegalStateException("Unsupported DagCompletionConfig: " + dcc); + } + + private static int countByStatus(Map> results, TaskStatus status) { + int n = 0; + for (var e : results.values()) { + if (e.status() == status) { + n++; + } + } + return n; + } + + private static TaskExecution succeeded(String name, Object result) { + return new TaskExecution<>( + name, + TaskStatus.SUCCEEDED, + Optional.empty(), + Optional.ofNullable(result), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + + private static TaskExecution failed(String name, DagTaskError error) { + return new TaskExecution<>( + name, + TaskStatus.FAILED, + Optional.empty(), + Optional.empty(), + Optional.of(error), + Optional.empty(), + Optional.empty()); + } + + private static TaskExecution skipped(String name, SkipReason reason) { + return new TaskExecution<>( + name, + TaskStatus.SKIPPED, + Optional.of(reason), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + + /** In-flight task launch. */ + private record InFlight(DurableFuture future) {} +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultImpl.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultImpl.java new file mode 100644 index 000000000..fc28e5cdd --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultImpl.java @@ -0,0 +1,243 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import software.amazon.lambda.durable.dag.DagCompletionReason; +import software.amazon.lambda.durable.dag.DagExecutionException; +import software.amazon.lambda.durable.dag.DagResult; +import software.amazon.lambda.durable.dag.TaskExecution; +import software.amazon.lambda.durable.dag.TaskHandle; +import software.amazon.lambda.durable.dag.TaskStatus; + +/** Concrete {@link DagResult} backed by the scheduler's terminal task-state map. */ +public final class DagResultImpl implements DagResult { + + private final Map> results; + private final DagCompletionReason completionReason; + private final int totalCount; + private final List startedTaskNames; + + /** + * Explicit per-status counts, used when this result was restored from an offloaded (tasks-less) envelope where the + * per-task {@link #results} map is legitimately empty but the aggregate counts are carried by the envelope and MUST + * be preserved (see the nested-offload contract, rule 1). {@code null} means "derive from the {@link #results} + * map", which is the correct behaviour for a live scheduler outcome and for an inline (tasks-carrying) round-trip + * where the map is fully populated. + */ + private final Integer explicitSuccessCount; + + private final Integer explicitFailureCount; + private final Integer explicitSkippedCount; + private final boolean replayState; + + /** + * Backward-compatible constructor for callers where every registered task settled (total == settled map size), e.g. + * unit tests and small-DAG serde round-trips that don't model early completion. + */ + public DagResultImpl(Map> results, DagCompletionReason completionReason) { + this(results, completionReason, results.size()); + } + + public DagResultImpl(Map> results, DagCompletionReason completionReason, int totalCount) { + this(results, completionReason, totalCount, List.of()); + } + + public DagResultImpl( + Map> results, + DagCompletionReason completionReason, + int totalCount, + List startedTaskNames) { + this(results, completionReason, totalCount, startedTaskNames, null, null, null); + } + + /** + * Full constructor that allows the three per-status counts to be supplied explicitly rather than derived from the + * {@code results} map. This is what preserves the aggregate when restoring from an offloaded envelope whose + * {@code tasks} list was dropped: the map is empty, but {@code successCount}/{@code failureCount}/ + * {@code skippedCount} still report the values the envelope carried (contract rule 1). Pass {@code null} for a + * count to derive it from the map (the inline / live-outcome case). + */ + public DagResultImpl( + Map> results, + DagCompletionReason completionReason, + int totalCount, + List startedTaskNames, + Integer successCount, + Integer failureCount, + Integer skippedCount) { + this(results, completionReason, totalCount, startedTaskNames, successCount, failureCount, skippedCount, false); + } + + private DagResultImpl( + Map> results, + DagCompletionReason completionReason, + int totalCount, + List startedTaskNames, + Integer successCount, + Integer failureCount, + Integer skippedCount, + boolean replayState) { + this.results = new LinkedHashMap<>(results); + this.completionReason = completionReason; + this.totalCount = totalCount; + this.startedTaskNames = List.copyOf(startedTaskNames); + this.explicitSuccessCount = successCount; + this.explicitFailureCount = failureCount; + this.explicitSkippedCount = skippedCount; + this.replayState = replayState; + } + + public static DagResultImpl from(DagExecutionOutcome outcome) { + return new DagResultImpl( + outcome.results(), outcome.completionReason(), outcome.totalCount(), outcome.startedTaskNames()); + } + + public static DagResultImpl replayState(DagResult result) { + return new DagResultImpl( + result.results(), + result.completionReason(), + result.totalCount(), + result.startedTaskNames(), + result.successCount(), + result.failureCount(), + result.skippedCount(), + true); + } + + public boolean isReplayState() { + return replayState; + } + + @SuppressWarnings("unchecked") + @Override + public Optional getResult(TaskHandle handle) { + return (Optional) getResult(handle.name()); + } + + @Override + public Optional getResult(String name) { + var exec = results.get(name); + if (exec == null || exec.status() != TaskStatus.SUCCEEDED) { + return Optional.empty(); + } + return Optional.ofNullable(exec.result().orElse(null)); + } + + @Override + public Optional getStatus(TaskHandle handle) { + return getStatus(handle.name()); + } + + @Override + public Optional getStatus(String name) { + var exec = results.get(name); + return exec == null ? Optional.empty() : Optional.of(exec.status()); + } + + @Override + public List> succeeded() { + return byStatus(TaskStatus.SUCCEEDED); + } + + @Override + public List> failed() { + return byStatus(TaskStatus.FAILED); + } + + @Override + public List> skipped() { + return byStatus(TaskStatus.SKIPPED); + } + + private List> byStatus(TaskStatus status) { + List> list = new ArrayList<>(); + for (var e : results.values()) { + if (e.status() == status) { + list.add(e); + } + } + return list; + } + + @Override + public Map> results() { + return Collections.unmodifiableMap(results); + } + + @Override + public int successCount() { + return explicitSuccessCount != null + ? explicitSuccessCount + : byStatus(TaskStatus.SUCCEEDED).size(); + } + + @Override + public int failureCount() { + return explicitFailureCount != null + ? explicitFailureCount + : byStatus(TaskStatus.FAILED).size(); + } + + @Override + public int skippedCount() { + return explicitSkippedCount != null + ? explicitSkippedCount + : byStatus(TaskStatus.SKIPPED).size(); + } + + @Override + public int totalCount() { + return totalCount; + } + + @Override + public DagCompletionReason completionReason() { + return completionReason; + } + + @Override + public List startedTaskNames() { + return startedTaskNames; + } + + @Override + public void throwIfError() { + if (failureCount() > 0) { + var failedList = failed(); + if (failedList.isEmpty()) { + // Restored from an offloaded (tasks-less) envelope: the aggregate states the DAG had failures, but the + // per-task detail is not present in this result (it lives in the retained child operations). Still + // honour the contract — never report success when the checkpoint says otherwise — by throwing with the + // aggregate failure count rather than a specific task. + throw new DagExecutionException( + "DAG completed with " + failureCount() + + " failed task(s); per-task detail unavailable (result restored from an offloaded checkpoint)"); + } + var first = failedList.get(0); + var cause = first.error().flatMap(e -> e.cause()).orElse(null); + var message = "DAG completed with " + failureCount() + " failed task(s); first failure: '" + + first.name() + "'" + + first.error() + .map(e -> " (" + e.errorType() + ": " + e.errorMessage() + ")") + .orElse(""); + if (cause != null) { + throw new DagExecutionException(message, cause); + } + throw new DagExecutionException(message); + } + if (completionReason() == DagCompletionReason.CUSTOM_COMPLETION_FAILED) { + // A custom shouldComplete predicate stopped the DAG as a failure even though no individual task + // FAILED (e.g. a rules-engine rejection based on a SUCCEEDED task's result). throwIfError() must + // still honour that verdict rather than silently reporting success. + throw new DagExecutionException( + "DAG completed with reason CUSTOM_COMPLETION_FAILED (a custom completion predicate" + + " completed the DAG as a failure)"); + } + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultSerDes.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultSerDes.java new file mode 100644 index 000000000..89aed58ec --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultSerDes.java @@ -0,0 +1,233 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.dag.DagResult; +import software.amazon.lambda.durable.dag.TaskExecution; +import software.amazon.lambda.durable.dag.TaskStatus; +import software.amazon.lambda.durable.model.MapResult; +import software.amazon.lambda.durable.serde.SerDes; + +/** + * SerDes for {@link DagResult} that (de)serializes the aggregate to the single cross-language DAG container envelope + * ({@link SerializedDagResult}): {@code type}, counts, {@code completionReason}, {@code startedTaskNames}, + * {@code failedTaskNames}, and an optional {@code tasks} list. The inline case carries {@code tasks}; the offloaded + * case drops {@code tasks} (see {@link #offloadPayloads}) so the console still renders the aggregate summary while the + * per-task detail lives in the retained child operations. + * + *

On restore, {@code batch} results rehydrate to {@link MapResult}, nested {@code dag} results recurse, and + * {@code plain} results rehydrate to their task's declared type recovered by name from {@link DagResultTypes} + * — never from a class name persisted in the checkpoint (so there is no {@code Class.forName} on untrusted input, and + * generic element types survive). An unknown/undeclared task falls back to a generic JSON tree. + */ +public final class DagResultSerDes implements SerDes { + private static final int LARGE_RESULT_THRESHOLD = 256 * 1024; + + private final SerDes delegate; + private final DagResultTypes types; + + public DagResultSerDes(SerDes delegate) { + this(delegate, DagResultTypes.empty()); + } + + public DagResultSerDes(SerDes delegate, DagResultTypes types) { + this.delegate = delegate; + this.types = types; + } + + @Override + public String serialize(Object value) { + if (value == null) { + return null; + } + if (value instanceof DagResult dr) { + if (dr instanceof DagResultImpl result && result.isReplayState()) { + return selectOffloadPayload(result); + } + return delegate.serialize(toEnvelope(dr, true, true)); + } + // Not a DAG aggregate. The DAG runs inside a child context whose result SerDes is this instance, so the same + // SerDes is also asked to serialize a Throwable when that child context fails (e.g. a throwing runIf surfacing + // as DagPredicateException). Delegate verbatim rather than casting to DagResult — the DAG's aggregate shaping + // must never corrupt error serialization. + return delegate.serialize(value); + } + + /** + * The ordered degradation ladder for a DAG aggregate that does not fit as the full inline envelope, largest first. + * Both candidates drop {@code tasks} (the per-task detail is preserved in the retained child operations via + * {@code ReplayChildren}); the second additionally drops {@code failedTaskNames}. Counts, {@code completionReason} + * and {@code startedTaskNames} are never dropped, so a DAG can never fail to checkpoint because its own summary did + * not fit. The extension context replay policy selects compact replay state when the full result crosses the + * configured threshold. + */ + public List offloadPayloads(DagResult dr) { + return List.of( + delegate.serialize(toEnvelope(dr, false, true)), delegate.serialize(toEnvelope(dr, false, false))); + } + + public DagResult replayState(DagResult result) { + return DagResultImpl.replayState(result); + } + + private String selectOffloadPayload(DagResult result) { + var payloads = offloadPayloads(result); + for (var payload : payloads) { + if (payload == null || payload.getBytes(StandardCharsets.UTF_8).length < LARGE_RESULT_THRESHOLD) { + return payload; + } + } + return payloads.get(payloads.size() - 1); + } + + @Override + public T deserialize(String data, TypeToken typeToken) { + if (data == null) { + return null; + } + // Only the DAG aggregate goes through the envelope shape; anything else (notably a Throwable being + // reconstructed for a failed child context) delegates so the typed exception survives the round-trip. + boolean isDagResult = typeToken.getType() instanceof Class c && DagResult.class.isAssignableFrom(c); + if (!isDagResult) { + return delegate.deserialize(data, typeToken); + } + var s = delegate.deserialize(data, TypeToken.get(SerializedDagResult.class)); + @SuppressWarnings("unchecked") + T result = (T) fromEnvelope(s, types); + return result; + } + + private SerializedDagResult toEnvelope(DagResult dr, boolean includeTasks, boolean includeFailedTaskNames) { + List failedTaskNames = includeFailedTaskNames + ? dr.failed().stream().map(TaskExecution::name).toList() + : null; + List tasks = null; + if (includeTasks) { + tasks = new ArrayList<>(); + for (var te : dr.results().values()) { + tasks.add(toSerializedTask(te)); + } + } + return new SerializedDagResult( + SerializedDagResult.TYPE, + dr.totalCount(), + dr.successCount(), + dr.failureCount(), + dr.skippedCount(), + dr.completionReason(), + List.copyOf(dr.startedTaskNames()), + failedTaskNames, + tasks); + } + + private SerializedTaskExecution toSerializedTask(TaskExecution te) { + Object resultObj = te.result().orElse(null); + SerializedResultKind kind; + Object serResult; + if (resultObj instanceof MapResult) { + kind = SerializedResultKind.BATCH; + serResult = resultObj; + } else if (resultObj instanceof DagResult nested) { + kind = SerializedResultKind.DAG; + serResult = toEnvelope(nested, true, true); + } else { + kind = SerializedResultKind.PLAIN; + serResult = resultObj; + } + // resultKind describes how to interpret `result`, so it is null when there is no + // result to interpret: a FAILED or SKIPPED task carries null for both. All four + // SDKs agree on this (envelope contract rule 1, explicit nulls). + if (te.status() != TaskStatus.SUCCEEDED) { + kind = null; + } + return new SerializedTaskExecution( + te.name(), + te.status(), + te.skipReason().orElse(null), + kind, + serResult, + te.error().orElse(null), + te.startedAt().map(Instant::toString).orElse(null), + te.completedAt().map(Instant::toString).orElse(null)); + } + + private DagResultImpl fromEnvelope(SerializedDagResult s, DagResultTypes scope) { + Map> results = new LinkedHashMap<>(); + // NOTE ON REACHABILITY: `tasks() == null` is the compact replay-state case. The extension context replays its + // children and re-derives a fully populated DagResultImpl before get() returns, so production execution does + // not normally expose this tasks-less result. The branch remains useful for tooling and direct SerDes tests, + // and preserves aggregate counts if a compact envelope is deserialized independently. + List tasks = s.tasks() == null ? List.of() : s.tasks(); + for (var ste : tasks) { + Optional result = Optional.empty(); + if (ste.status() == TaskStatus.SUCCEEDED) { + result = Optional.ofNullable(rehydrate(ste.resultKind(), ste.result(), ste.name(), scope)); + } + results.put( + ste.name(), + new TaskExecution<>( + ste.name(), + ste.status(), + Optional.ofNullable(ste.skipReason()), + result, + Optional.ofNullable(ste.error()), + Optional.ofNullable(ste.startedAt()).map(Instant::parse), + Optional.ofNullable(ste.completedAt()).map(Instant::parse))); + } + List startedTaskNames = s.startedTaskNames() == null ? List.of() : s.startedTaskNames(); + // Preserve the aggregate counts carried by the envelope rather than re-deriving them from `results`. In the + // inline case the two agree (the map is fully populated). In the offloaded case `tasks` was dropped so + // `results` + // is empty, but the envelope still carries the counts — and contract rule 1 requires that restoring a + // tasks-less envelope preserve totalCount, the three counts and completionReason. Deriving from the (empty) map + // would fabricate zeroed counts and could report a failed DAG as having zero failures. + return new DagResultImpl( + results, + s.completionReason(), + s.totalCount(), + startedTaskNames, + s.successCount(), + s.failureCount(), + s.skippedCount()); + } + + private Object rehydrate(SerializedResultKind kind, Object raw, String taskName, DagResultTypes scope) { + if (raw == null) { + return null; + } + return switch (kind) { + case PLAIN -> rehydratePlain(raw, taskName, scope); + case BATCH -> delegate.deserialize(delegate.serialize(raw), TypeToken.get(MapResult.class)); + case DAG -> + fromEnvelope( + delegate.deserialize(delegate.serialize(raw), TypeToken.get(SerializedDagResult.class)), + scope.nestedScope(taskName)); + }; + } + + /** + * Rehydrates a PLAIN result to the task's declared type when that task is known in this scope, so POJO / record / + * collection results survive replay of a small completed DAG rather than degrading to a generic JSON tree. The type + * is recovered from the registered graph by task name (never from a checkpoint-stored class name); an unknown task, + * or any (de)serialization failure, falls back to the raw parsed tree. + */ + private Object rehydratePlain(Object raw, String taskName, DagResultTypes scope) { + var declared = scope.plainType(taskName); + if (declared.isEmpty()) { + return raw; + } + try { + return delegate.deserialize(delegate.serialize(raw), declared.get()); + } catch (RuntimeException e) { + return raw; + } + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultTypes.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultTypes.java new file mode 100644 index 000000000..25c698e90 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultTypes.java @@ -0,0 +1,54 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import java.util.Map; +import java.util.Optional; +import software.amazon.lambda.durable.TypeToken; + +/** + * The declared result types of a DAG scope, keyed by task name, used to rehydrate {@code PLAIN} task results on replay + * without persisting a class name in the checkpoint. Internal. + * + *

This is the replacement for the former checkpoint-stored {@code resultType} field. On replay we recover a + * {@code PLAIN} result's type from the registered graph — the {@link TypeToken} the task was declared with — + * looked up by the task's name, rather than from an untrusted class name read out of the checkpoint. Consequences: + * + *

    + *
  • The customer-facing envelope no longer carries a {@code resultType} field. + *
  • There is no {@code Class.forName} on any checkpoint-supplied string — only compile-time {@link TypeToken}s from + * the registered graph are ever used, so the arbitrary-class-load / static-initializer surface is removed + * entirely. + *
  • A task name absent from the map (unknown / tampered / never declared) simply falls back to a generic JSON tree. + *
  • Because we use the full declared {@link TypeToken}, generic element types (e.g. {@code List}) can now be + * rehydrated faithfully rather than erasing to a list of trees. + *
+ * + * @param plain task name → declared result type for this scope's tasks (a map/parallel task's {@code MapResult} and a + * nested {@code dag}'s {@code DagResult} are rehydrated structurally and are not required here) + * @param nested nested-{@code dag} task name → that nested scope's own {@link DagResultTypes} + */ +public record DagResultTypes(Map> plain, Map nested) { + + private static final DagResultTypes EMPTY = new DagResultTypes(Map.of(), Map.of()); + + /** An empty type graph — nothing is reconstructable; every PLAIN result degrades to a generic JSON tree. */ + public static DagResultTypes empty() { + return EMPTY; + } + + public DagResultTypes { + plain = Map.copyOf(plain); + nested = Map.copyOf(nested); + } + + /** The declared type for a PLAIN task result by name, if the task was registered in this scope. */ + public Optional> plainType(String taskName) { + return Optional.ofNullable(plain.get(taskName)); + } + + /** The nested scope for a nested-{@code dag} task by name, or an empty graph if unknown. */ + public DagResultTypes nestedScope(String taskName) { + return nested.getOrDefault(taskName, EMPTY); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagValidator.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagValidator.java new file mode 100644 index 000000000..16728977d --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagValidator.java @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.regex.Pattern; +import software.amazon.lambda.durable.dag.DagCyclicDependencyException; +import software.amazon.lambda.durable.dag.DagDuplicateTaskException; +import software.amazon.lambda.durable.dag.DagInvalidDependencyException; +import software.amazon.lambda.durable.dag.DagInvalidTaskNameException; +import software.amazon.lambda.durable.dag.TaskHandle; + +/** + * Registration-time DAG graph validation. Runs once, after registration returns, before any task launches. Enforces the + * DAG-layer name charset (stricter than the base SDK), rejects duplicates and foreign dependencies, and detects cycles + * via Kahn's algorithm ({@code O(V+E)}). A diamond is not a cycle. + */ +public final class DagValidator { + + /** DAG-layer task-name pattern: alphanumeric + underscore only, no dash. */ + private static final Pattern NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$"); + + private static final int MAX_NAME_LENGTH = 100; + + /** Reserved structural delimiter that names must not contain (defense-in-depth for name-based IDs). */ + private static final String RESERVED_DELIMITER = "DAG_NODE_T_"; + + private DagValidator() {} + + /** + * Validates the registered tasks (in registration order). + * + * @param tasks the registered task handles + * @throws DagInvalidTaskNameException on a bad task name + * @throws DagDuplicateTaskException on a duplicate name + * @throws DagInvalidDependencyException on a dependency not registered in this scope + * @throws DagCyclicDependencyException if the dependency graph contains a cycle + */ + public static void validate(List> tasks) { + Set> registered = + java.util.Collections.newSetFromMap(new IdentityHashMap, Boolean>()); + var names = new HashSet(); + + for (var task : tasks) { + validateName(task.name()); + if (!names.add(task.name())) { + throw new DagDuplicateTaskException("Duplicate DAG task name: '" + task.name() + "'"); + } + registered.add(task); + } + + // Foreign / unregistered dependency check. + for (var task : tasks) { + for (var dep : task.allDeps()) { + if (!registered.contains(dep)) { + throw new DagInvalidDependencyException("Task '" + task.name() + "' depends on task '" + dep.name() + + "' which is not registered in this DAG scope"); + } + } + } + + detectCycles(tasks); + } + + private static void validateName(String name) { + if (name == null || name.isEmpty()) { + throw new DagInvalidTaskNameException("DAG task name must be non-empty"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new DagInvalidTaskNameException( + "DAG task name exceeds " + MAX_NAME_LENGTH + " chars: '" + name + "'"); + } + if (!NAME_PATTERN.matcher(name).matches()) { + throw new DagInvalidTaskNameException("DAG task name must match ^[a-zA-Z0-9_]+$ (no dash): '" + name + "'"); + } + if (name.contains(RESERVED_DELIMITER)) { + throw new DagInvalidTaskNameException("DAG task name must not contain the reserved sequence '" + + RESERVED_DELIMITER + "': '" + name + "'"); + } + } + + /** Kahn's algorithm over {@code allDeps}. */ + private static void detectCycles(List> tasks) { + // Map each handle to its outstanding in-degree (number of unresolved dependencies). + Map, Integer> inDegree = new IdentityHashMap<>(); + // dependents: dep -> list of tasks that depend on it. + Map, java.util.List>> dependents = new IdentityHashMap<>(); + + for (var task : tasks) { + inDegree.putIfAbsent(task, 0); + dependents.putIfAbsent(task, new java.util.ArrayList<>()); + } + for (var task : tasks) { + for (var dep : task.allDeps()) { + inDegree.merge(task, 1, Integer::sum); + dependents.get(dep).add(task); + } + } + + Queue> ready = new ArrayDeque<>(); + for (var e : inDegree.entrySet()) { + if (e.getValue() == 0) { + ready.add(e.getKey()); + } + } + + int resolved = 0; + while (!ready.isEmpty()) { + var t = ready.poll(); + resolved++; + for (var dependent : dependents.get(t)) { + if (inDegree.merge(dependent, -1, Integer::sum) == 0) { + ready.add(dependent); + } + } + } + + if (resolved != tasks.size()) { + var cyclic = new HashMap(); + for (var e : inDegree.entrySet()) { + if (e.getValue() > 0) { + cyclic.put(((TaskHandle) e.getKey()).name(), true); + } + } + throw new DagCyclicDependencyException( + "DAG contains a cyclic dependency involving tasks: " + cyclic.keySet()); + } + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DepsImpl.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DepsImpl.java new file mode 100644 index 000000000..633e32e68 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DepsImpl.java @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import software.amazon.lambda.durable.dag.Deps; +import software.amazon.lambda.durable.dag.TaskExecution; +import software.amazon.lambda.durable.dag.TaskHandle; +import software.amazon.lambda.durable.dag.TaskStatus; + +/** + * {@link Deps} implementation backing a single task. Only the task's inline dependencies (declared via + * {@code reads(...)}) are retrievable; results are resolved by task name from an immutable per-task snapshot of + * those dependencies' terminal executions, taken by the scheduler at launch time (all inline deps are terminal before + * the task is launched). The snapshot is private to this task, so reads here never race the scheduler's writes to its + * live results map. + */ +final class DepsImpl implements Deps { + + private final String taskName; + private final Set> inlineDeps; + private final Map> results; + + DepsImpl(String taskName, java.util.List> inlineDeps, Map> results) { + this.taskName = taskName; + Set> set = Collections.newSetFromMap(new IdentityHashMap<>()); + set.addAll(inlineDeps); + this.inlineDeps = set; + this.results = results; + } + + @SuppressWarnings("unchecked") + @Override + public Optional get(TaskHandle handle) { + requireDeclared(handle); + var exec = results.get(handle.name()); + if (exec == null || exec.status() != TaskStatus.SUCCEEDED) { + return Optional.empty(); + } + return (Optional) exec.result(); + } + + private void requireDeclared(TaskHandle handle) { + if (!inlineDeps.contains(handle)) { + throw new IllegalStateException("Task '" + taskName + "' called deps.get(...) for '" + handle.name() + + "' but never declared it as an inline dependency. Java cannot inspect a lambda body to see" + + " which handles it reads, so chain .reads(...) with the '" + handle.name() + + "' handle onto '" + taskName + "'s own registration (the TaskHandle returned when '" + taskName + + "' was registered, e.g. d.step(\"" + taskName + "\", ...).reads(...)) -- not onto '" + + handle.name() + "'s registration."); + } + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java new file mode 100644 index 000000000..e911b9d2a --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import com.amazonaws.services.lambda.runtime.Context; +import java.util.Objects; +import org.slf4j.Logger; +import software.amazon.lambda.durable.DurableConfig; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.extension.ExtensionOperation; +import software.amazon.lambda.durable.logging.DurableLogger; + +/** Supplies one pre-reserved DAG operation to an existing durable operation facade. */ +final class ReservedOperationContext implements ExtensionContext { + private final ExtensionContext delegate; + private final String operationName; + private final ExtensionOperation operation; + private boolean reserved; + + ReservedOperationContext(ExtensionContext delegate, String operationName, ExtensionOperation operation) { + this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null"); + this.operationName = Objects.requireNonNull(operationName, "operationName cannot be null"); + this.operation = Objects.requireNonNull(operation, "operation cannot be null"); + } + + @Override + public boolean isReplaying() { + return delegate.isReplaying(); + } + + @Override + public synchronized ExtensionOperation reserve(String name) { + if (!reserved) { + if (!operationName.equals(name)) { + throw new IllegalStateException( + "Expected operation '" + operationName + "' to be reserved before '" + name + "'"); + } + reserved = true; + return operation; + } + return delegate.reserve(name); + } + + @Override + public ExtensionOperation reserve(String name, String localOperationId) { + return delegate.reserve(name, localOperationId); + } + + @Override + public DurableLogger getLogger() { + return delegate.getLogger(); + } + + @Override + public DurableLogger getLogger(Logger logger) { + return delegate.getLogger(logger); + } + + @Override + public Context getLambdaContext() { + return delegate.getLambdaContext(); + } + + @Override + public String getExecutionArn() { + return delegate.getExecutionArn(); + } + + @Override + public DurableConfig getDurableConfig() { + return delegate.getDurableConfig(); + } + + @Override + public String getContextId() { + return delegate.getContextId(); + } + + @Override + public String getContextName() { + return delegate.getContextName(); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedDagResult.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedDagResult.java new file mode 100644 index 000000000..d79b07ea6 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedDagResult.java @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import software.amazon.lambda.durable.dag.DagCompletionReason; + +/** + * JSON-safe serialized form of a {@link software.amazon.lambda.durable.dag.DagResult} — the single cross-language DAG + * container envelope written identically by all four SDKs, for both the inline and the offloaded case. Internal. + * + *

Field order matches the cross-language envelope for console readability. The aggregate fields ({@code type}, + * counts, {@code completionReason}, {@code startedTaskNames}) are ALWAYS present. Two fields are droppable by the + * degradation ladder and are therefore omitted (not emitted as {@code null}) when dropped: + * + *

    + *
  • {@code tasks} — its absence is the signal that per-task detail was too large and lives in the retained child + * operations instead (the offloaded case). There is no flag field: absence is the signal. + *
  • {@code failedTaskNames} — dropped as the last space-saving step before offload; still bounded/recoverable. + *
+ * + *

Evolution is additive-only (there is no {@code schemaVersion}), so this reader ignores unknown fields + * ({@link JsonIgnoreProperties}) and treats a missing field as absent rather than failing. + * + * @param type the envelope discriminator; always {@code "DagResult"} + * @param totalCount number of registered tasks (fixed; independent of early completion) + * @param successCount number of SUCCEEDED tasks + * @param failureCount number of FAILED tasks + * @param skippedCount number of SKIPPED tasks + * @param completionReason why the DAG finished + * @param startedTaskNames names of tasks started but not terminal at early completion (bounded by + * {@code maxConcurrency}); empty on a full drain + * @param failedTaskNames names of FAILED tasks; omitted (null) only as the last degradation step before offload + * @param tasks the serialized task executions in registration order, or {@code null} (omitted) when offloaded + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.ALWAYS) +public record SerializedDagResult( + String type, + int totalCount, + int successCount, + int failureCount, + int skippedCount, + DagCompletionReason completionReason, + List startedTaskNames, + @JsonInclude(JsonInclude.Include.NON_NULL) List failedTaskNames, + @JsonInclude(JsonInclude.Include.NON_NULL) List tasks) { + + /** The canonical {@code type} discriminator value for a DAG container envelope. */ + public static final String TYPE = "DagResult"; +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedResultKind.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedResultKind.java new file mode 100644 index 000000000..244e5af1f --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedResultKind.java @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Discriminator tagging how a task's result must be rehydrated on deserialization. Internal. + * + *

Serializes to the cross-language canonical {@code resultKind} vocabulary — {@code plain} | {@code batch} | + * {@code dag}, lowercase (envelope convergence contract). The wire value, not the Java constant name, is authoritative; + * {@link #BATCH} is Java's {@code MapResult} (a map or parallel task's batch aggregate). + */ +public enum SerializedResultKind { + /** A plain JSON value; rehydrated to the task's declared result type when known, else a generic JSON tree. */ + PLAIN("plain"), + /** A {@code MapResult} (from a map or parallel task) — recursively rehydrated. Wire value {@code "batch"}. */ + BATCH("batch"), + /** A nested {@code DagResult} — recursively rehydrated. */ + DAG("dag"); + + private final String value; + + SerializedResultKind(String value) { + this.value = value; + } + + /** The lowercase cross-language wire value. */ + @JsonValue + public String value() { + return value; + } + + /** Parses a wire value back to its constant (additive-evolution tolerant callers should guard nulls). */ + @JsonCreator + public static SerializedResultKind fromValue(String value) { + for (var kind : values()) { + if (kind.value.equals(value)) { + return kind; + } + } + throw new IllegalArgumentException("Unknown resultKind: " + value); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedTaskExecution.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedTaskExecution.java new file mode 100644 index 000000000..f782087fd --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedTaskExecution.java @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import software.amazon.lambda.durable.dag.DagTaskError; +import software.amazon.lambda.durable.dag.SkipReason; +import software.amazon.lambda.durable.dag.TaskStatus; + +/** + * JSON-safe serialized form of a {@link software.amazon.lambda.durable.dag.TaskExecution}. Internal. + * + *

Field order matches the cross-language envelope task shape for console readability: {@code name}, {@code status}, + * {@code skipReason}, {@code resultKind}, {@code result}, {@code error}, {@code startedAt}, {@code completedAt}. + * + *

There is deliberately no {@code resultType} field: a {@code PLAIN} result is rehydrated on replay from the task's + * declared result type (recovered by task name from the registered graph — see {@link DagResultTypes}), not + * from a class name persisted in the checkpoint. That both removes the field from the customer-facing payload and + * eliminates any {@code Class.forName} on a checkpoint-supplied string. + * + * @param name the task name + * @param status the terminal status + * @param skipReason the skip reason, or {@code null} + * @param resultKind how {@code result} must be rehydrated ({@code plain} | {@code batch} | {@code dag}) + * @param result the (kind-tagged) result payload, or {@code null} + * @param error the failure error, or {@code null} + * @param startedAt ISO-8601 UTC start time, or {@code null} + * @param completedAt ISO-8601 UTC completion time, or {@code null} + */ +public record SerializedTaskExecution( + String name, + TaskStatus status, + SkipReason skipReason, + SerializedResultKind resultKind, + Object result, + DagTaskError error, + String startedAt, + String completedAt) {} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskExecutor.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskExecutor.java new file mode 100644 index 000000000..1e591d7bc --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskExecutor.java @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.dag.Deps; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.extension.ExtensionOperation; + +/** + * Internal closure that launches a task through a stable extension-operation reservation. Created at registration time + * and invoked by the scheduler when the task is ready. + * + * @param the task result type + */ +@FunctionalInterface +public interface TaskExecutor { + + /** + * Launches the underlying operation through the public extension SPI. + * + * @param ctx the DAG child context to launch the operation in + * @param operation the task's stable one-shot reservation + * @param deps resolved upstream results for this task + * @return a future representing the task result + */ + DurableFuture launch(ExtensionContext ctx, ExtensionOperation operation, Deps deps); +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskHandleImpl.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskHandleImpl.java new file mode 100644 index 000000000..64945e118 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskHandleImpl.java @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import software.amazon.lambda.durable.dag.Deps; +import software.amazon.lambda.durable.dag.TaskHandle; +import software.amazon.lambda.durable.dag.TriggerRule; + +/** + * Concrete, mutable {@link TaskHandle} produced during DAG registration. Carries the task's kind, executor closure, + * declared dependencies, trigger rule, and {@code runIf} predicate. Consumed directly by the scheduler + * ({@link DagExecutor}) via its accessors. + * + * @param the task result type + */ +public final class TaskHandleImpl implements TaskHandle { + + private final String name; + private final TaskKind kind; + private final TaskExecutor executor; + private final Object options; + private final List> inlineDeps = new ArrayList<>(); + private final List> extraDeps = new ArrayList<>(); + private TriggerRule triggerRule; + private Predicate runIf; + + public TaskHandleImpl(String name, TaskKind kind, TaskExecutor executor, Object options) { + this.name = name; + this.kind = kind; + this.executor = executor; + this.options = options; + } + + @Override + public String name() { + return name; + } + + @Override + public TaskHandle reads(TaskHandle... deps) { + for (var d : deps) { + if (!inlineDeps.contains(d)) { + inlineDeps.add(d); + } + } + return this; + } + + @Override + public TaskHandle after(TaskHandle... deps) { + for (var d : deps) { + if (!extraDeps.contains(d)) { + extraDeps.add(d); + } + } + return this; + } + + @Override + public TaskHandle triggerRule(TriggerRule rule) { + this.triggerRule = rule; + return this; + } + + @Override + public TaskHandle runIf(Predicate predicate) { + this.runIf = predicate; + return this; + } + + public TaskKind kind() { + return kind; + } + + public TaskExecutor executor() { + return executor; + } + + public List> inlineDeps() { + return inlineDeps; + } + + /** Union of inline (reads) and ordering-only (after) dependencies, de-duplicated, inline first. */ + public List> allDeps() { + var all = new ArrayList>(inlineDeps); + for (var d : extraDeps) { + if (!all.contains(d)) { + all.add(d); + } + } + return all; + } + + public Optional triggerRuleOpt() { + return Optional.ofNullable(triggerRule); + } + + public Optional> runIfOpt() { + return Optional.ofNullable(runIf); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskKind.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskKind.java new file mode 100644 index 000000000..d16f8bd09 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskKind.java @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +/** The kind of durable operation a DAG task delegates to. Internal. */ +public enum TaskKind { + STEP, + INVOKE, + CALLBACK, + WAIT, + WAIT_FOR_CONDITION, + CHILD, + MAP, + PARALLEL, + DAG +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluator.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluator.java new file mode 100644 index 000000000..0d7d7a7b5 --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluator.java @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import java.util.List; +import software.amazon.lambda.durable.dag.TaskStatus; +import software.amazon.lambda.durable.dag.TriggerRule; + +/** + * Internal evaluator for {@link TriggerRule}. Keeps scheduler truth-table logic off the public value enum: given a rule + * and the terminal statuses of a task's upstream dependencies, decides whether the task should run. + * + *

Each rule defines its behavior for the empty-upstream (vacuous) case used by root tasks or tasks whose dependency + * set is empty. + */ +final class TriggerRuleEvaluator { + + private TriggerRuleEvaluator() {} + + /** + * Evaluates a rule against the terminal statuses of a task's upstream dependencies. + * + * @param rule the trigger rule + * @param statuses the terminal statuses of the upstream dependencies (may be empty) + * @return {@code true} if the task should run, {@code false} if it should be skipped + */ + static boolean eval(TriggerRule rule, List statuses) { + return switch (rule) { + // Empty upstream: vacuously true. + case ALL_SUCCESS -> statuses.stream().allMatch(s -> s == TaskStatus.SUCCEEDED); + // Empty upstream: skip. + case ALL_FAILED -> !statuses.isEmpty() && statuses.stream().allMatch(s -> s == TaskStatus.FAILED); + // Empty upstream: run. + case ALL_DONE -> true; + // Empty upstream: skip. + case ANY_SUCCESS -> statuses.stream().anyMatch(s -> s == TaskStatus.SUCCEEDED); + // Empty upstream: skip. + case ANY_FAILED -> statuses.stream().anyMatch(s -> s == TaskStatus.FAILED); + // Empty upstream: run. + case NONE_FAILED -> statuses.stream().noneMatch(s -> s == TaskStatus.FAILED); + }; + } +} diff --git a/dag/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java b/dag/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java new file mode 100644 index 000000000..821fd71db --- /dev/null +++ b/dag/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java @@ -0,0 +1,1281 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.lambda.model.OperationStatus; +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.dag.DagCompletionConfig; +import software.amazon.lambda.durable.dag.DagCompletionReason; +import software.amazon.lambda.durable.dag.DagConfig; +import software.amazon.lambda.durable.dag.DagPredicateException; +import software.amazon.lambda.durable.dag.DagResult; +import software.amazon.lambda.durable.dag.TaskStatus; +import software.amazon.lambda.durable.dag.TriggerRule; +import software.amazon.lambda.durable.dag.internal.DagExecutor; +import software.amazon.lambda.durable.execution.OperationIdGenerator; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.retry.RetryStrategies; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +/** End-to-end DAG tests via the local runner. */ +class DagIntegrationTest { + + @Test + void throwingRunIfAbortsDagAndCallerCatchesTypedExceptionWithCause() { + // A throwing runIf ABORTS the DAG with a typed DagPredicateException (contract H5), rather than recording the + // task FAILED or SKIPPED. The dag(...) caller can catch the typed exception; its message and taskName name the + // offending task and its cause carries the original error. (This is the exception the caller observes after it + // crosses the DAG child-context boundary: it is checkpointed and reconstructed from its serialized form.) + var caught = new java.util.concurrent.atomic.AtomicReference(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + try { + dag("cond", d -> { + var gate = d.step("gate", Integer.class, (deps, s) -> 7); + d.step("maybe", String.class, (deps, s) -> "ran") + .reads(gate) + .runIf(deps -> { + throw new IllegalStateException("predicate boom"); + }); + }); + return "no-throw"; + } catch (DagPredicateException e) { + caught.set(e); + return "caught"; + } + }); + + var result = runner.runUntilComplete("go"); + // The caller handled the typed exception, so the execution completes. + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("caught", result.getResult(String.class)); + + // The reconstructed exception the caller observes is a typed DagPredicateException that names the offending + // task (both via taskName() and its message) and whose cause carries the original error and its stack trace. + DagPredicateException e = caught.get(); + org.junit.jupiter.api.Assertions.assertNotNull(e, "caller must observe a DagPredicateException"); + assertEquals("maybe", e.taskName()); + org.junit.jupiter.api.Assertions.assertTrue( + e.getMessage().contains("maybe") && e.getMessage().contains("predicate boom"), e.getMessage()); + org.junit.jupiter.api.Assertions.assertNotNull(e.getCause(), "the original error must be retrievable as cause"); + assertEquals("predicate boom", e.getCause().getMessage()); + } + + @Test + void throwingRunIfLeavesNoTerminalStateAndRunsNoCompensation() { + // The abort is durable and wire-visible: the DAG container checkpoints FAILED, the offending task has NO + // terminal state, an already-run upstream keeps its SUCCEEDED checkpoint, and a downstream ALL_FAILED + // compensation task never runs (the defect must not drive compensation). The top-level execution fails with + // the typed DagPredicateException naming the task and the original error. + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("cond", d -> { + var gate = d.step("gate", Integer.class, (deps, s) -> 7); + var maybe = d.step("maybe", String.class, (deps, s) -> "ran") + .reads(gate) + .runIf(deps -> { + throw new IllegalStateException("predicate boom"); + }); + d.step("refund", String.class, (deps, s) -> "refunded") + .after(maybe) + .triggerRule(TriggerRule.ALL_FAILED); + }); + return "unreached:" + r.completionReason().name(); + }); + + var result = runner.runUntilComplete("go"); + + assertEquals(ExecutionStatus.FAILED, result.getStatus()); + + // The DAG container failed (wire-visible abort) and its checkpoint carries the typed error intact: type is + // DagPredicateException, message names the offending task and the original error, and the serialized cause + // chain preserves the original error. This is the durable, history-visible record of the abort. + var container = result.getOperation("cond"); + assertEquals(OperationStatus.FAILED, container.getStatus()); + var error = container.getContextDetails().error(); + org.junit.jupiter.api.Assertions.assertNotNull(error, "DAG container must checkpoint the failure error"); + assertEquals("software.amazon.lambda.durable.dag.DagPredicateException", error.errorType()); + org.junit.jupiter.api.Assertions.assertTrue( + error.errorMessage().contains("maybe"), + "message must name the offending task: " + error.errorMessage()); + org.junit.jupiter.api.Assertions.assertTrue( + error.errorMessage().contains("IllegalStateException") + && error.errorMessage().contains("predicate boom"), + "message must identify the original error: " + error.errorMessage()); + // The serialized cause chain preserves the original error (retrievable as the cause). + org.junit.jupiter.api.Assertions.assertTrue( + error.errorData() != null && error.errorData().contains("predicate boom"), + "errorData must carry the original cause"); + + // The already-run upstream kept its terminal SUCCEEDED state ... + assertEquals(OperationStatus.SUCCEEDED, result.getOperation("gate").getStatus()); + // ... the offending task has NO terminal state (it was never launched) ... + org.junit.jupiter.api.Assertions.assertNull( + result.getOperation("maybe"), "offending task must have no terminal state"); + // ... and the downstream ALL_FAILED compensation never ran. + org.junit.jupiter.api.Assertions.assertNull( + result.getOperation("refund"), "downstream ALL_FAILED compensation must not run"); + } + + @Test + void positionalArityTypedDepsSugarResolves() { + // C9: the 1..3-arity sugar passes upstream results directly (typed via the handle) and desugars to + // step(...).reads(...); dependency wiring is identical to the .reads() + Deps.get() form. + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("sugar", d -> { + var a = d.step("a", Integer.class, (deps, s) -> 1); + var b = d.step("b", Integer.class, a, (Integer av, StepContext s) -> av + 1); // 1-arity + var c = d.step("c", Integer.class, a, b, (Integer av, Integer bv, StepContext s) -> av + bv); // 2-arity + d.step( + "dd", + String.class, + a, + b, + c, + (Integer av, Integer bv, Integer cv, StepContext s) -> av + "-" + bv + "-" + cv); // 3-arity + }); + return (String) r.getResult("dd").orElse("MISSING") + "|" + + r.getResult("c").map(Object::toString).orElse("?"); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + // a=1, b=2, c=3, dd="1-2-3" + assertEquals("1-2-3|3", result.getResult(String.class)); + } + + @Test + void diamondResolvesWithTypedDeps() { + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("etl", d -> { + var a = d.step("a", String.class, (deps, s) -> "A"); + var b = d.step("b", String.class, (deps, s) -> deps.get(a).orElseThrow() + "B") + .reads(a); + var c = d.step("c", String.class, (deps, s) -> deps.get(a).orElseThrow() + "C") + .reads(a); + d.step( + "dd", + String.class, + (deps, s) -> + deps.get(b).orElseThrow() + deps.get(c).orElseThrow()) + .reads(b, c); + }); + return (String) r.getResult("dd").orElse("MISSING"); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("ABAC", result.getResult(String.class)); + } + + @Test + void runIfSkipCascades() { + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("cond", d -> { + var gate = d.step("gate", Integer.class, (deps, s) -> 0); + var maybe = d.step("maybe", String.class, (deps, s) -> "ran") + .reads(gate) + .runIf(deps -> ((Integer) deps.get(gate).orElseThrow()) > 0); + d.step("after", String.class, (deps, s) -> "after").after(maybe); + }); + return r.getStatus("maybe").map(Enum::name).orElse("?") + + "|" + + r.getStatus("after").map(Enum::name).orElse("?"); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + // maybe skipped (runIf false); after has ALL_SUCCESS default over a SKIPPED upstream -> skipped too + assertEquals(TaskStatus.SKIPPED.name() + "|" + TaskStatus.SKIPPED.name(), result.getResult(String.class)); + } + + @Test + void failureDrainsWithCompensation() { + var noRetry = StepConfig.builder() + .retryStrategy(RetryStrategies.Presets.NO_RETRY) + .build(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("saga", d -> { + var charge = d.step( + "charge", + String.class, + (deps, s) -> { + throw new RuntimeException("charge failed"); + }, + noRetry); + d.step("refund", String.class, (deps, s) -> "refunded") + .after(charge) + .triggerRule(TriggerRule.ALL_FAILED); + d.step("fulfill", String.class, (deps, s) -> "fulfilled").after(charge); + d.step("audit", String.class, (deps, s) -> "audited") + .after(charge) + .triggerRule(TriggerRule.ALL_DONE); + }); + return r.completionReason().name() + + "|" + r.getStatus("charge").map(Enum::name).orElse("?") + + "|" + r.getStatus("refund").map(Enum::name).orElse("?") + + "|" + r.getStatus("fulfill").map(Enum::name).orElse("?") + + "|" + r.getStatus("audit").map(Enum::name).orElse("?"); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("COMPLETED_WITH_FAILURES|FAILED|SUCCEEDED|SKIPPED|SUCCEEDED", result.getResult(String.class)); + } + + /** + * Proves the {@code Deps.get} contract at execution time: a compensation task that declares a failing task as an + * inline dependency ({@code reads}) and runs anyway via a non-ALL_SUCCESS trigger rule ({@code ALL_DONE}) reads + * that dependency inside its body and observes {@link java.util.Optional#empty()} — matching the long-standing + * runtime behavior now made honest by the {@code Optional} return type. + */ + @Test + void failedInlineDependencyReadsAsEmptyOptionalUnderAllDone() { + var noRetry = StepConfig.builder() + .retryStrategy(RetryStrategies.Presets.NO_RETRY) + .build(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("compensation", d -> { + var charge = d.step( + "charge", + String.class, + (deps, s) -> { + throw new RuntimeException("charge failed"); + }, + noRetry); + d.step("compensate", String.class, (deps, s) -> deps.get(charge).isPresent() ? "present" : "empty") + .reads(charge) + .triggerRule(TriggerRule.ALL_DONE); + }); + return (String) r.getResult("compensate").orElse("MISSING"); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("empty", result.getResult(String.class)); + } + + @Test + void replayAfterWaitDoesNotReexecuteCompletedTasks() { + var executions = new java.util.concurrent.atomic.AtomicInteger(0); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("with_wait", d -> { + var a = d.step("a", String.class, (deps, s) -> { + executions.incrementAndGet(); + return "A"; + }); + var w = d.wait("w", java.time.Duration.ofMinutes(5)).after(a); + d.step("b", String.class, (deps, s) -> deps.get(a).orElseThrow() + "B") + .reads(a) + .after(w); + }); + return (String) r.getResult("b").orElse("MISSING"); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("AB", result.getResult(String.class)); + // Step "a" ran exactly once despite the wait-induced suspension/replay (name-based ID fast-path). + assertEquals(1, executions.get()); + } + + @Test + void emptyDagCompletesImmediately() { + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("empty", d -> {}); + return r.totalCount() + "|" + r.completionReason().name(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("0|" + DagCompletionReason.ALL_COMPLETED.name(), result.getResult(String.class)); + } + + @Test + void nestedDagScopeIsolation() { + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("outer", d -> { + var root = d.step("root", String.class, (deps, s) -> "R"); + d.dag("inner", inner -> { + var x = inner.step("x", String.class, (deps, s) -> "X"); + inner.step( + "y", + String.class, + (deps, s) -> deps.get(x).orElseThrow() + "Y") + .reads(x); + }) + .after(root); + }); + DagResult innerDag = (DagResult) r.getResult("inner").orElseThrow(); + return innerDag.getResult("y").map(Object::toString).orElse("MISSING") + "|" + + innerDag.completionReason().name(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("XY|" + DagCompletionReason.ALL_COMPLETED.name(), result.getResult(String.class)); + } + + @Test + void minSuccessfulTriggersEarlyCompletion() { + var config = DagConfig.builder() + .completionConfig(DagCompletionConfig.minSuccessful(1)) + .build(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "early", + d -> { + d.step("a", String.class, (deps, s) -> "A"); + d.step("b", String.class, (deps, s) -> "B"); + d.step("c", String.class, (deps, s) -> "C"); + }, + config); + return r.completionReason().name() + "|" + r.successCount(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + // First success reaches the threshold; reason is MIN_SUCCESSFUL_REACHED with >= 1 success recorded. + assertEquals(DagCompletionReason.MIN_SUCCESSFUL_REACHED.name() + "|1", result.getResult(String.class)); + } + + @Test + void toleratedFailureCountExceededTriggersEarlyCompletion() { + var noRetry = StepConfig.builder() + .retryStrategy(RetryStrategies.Presets.NO_RETRY) + .build(); + var config = DagConfig.builder() + .completionConfig(DagCompletionConfig.toleratedFailureCount(0)) + .build(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "failfast", + d -> { + d.step( + "boom", + String.class, + (deps, s) -> { + throw new RuntimeException("kaboom"); + }, + noRetry); + }, + config); + return r.completionReason().name() + "|" + r.failureCount(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals(DagCompletionReason.FAILURE_TOLERANCE_EXCEEDED.name() + "|1", result.getResult(String.class)); + } + + @Test + void customCompletionShortCircuitsOnRejectedVerdict() { + // DAG-18-style rules engine: a linear chain r1 -> r2 -> r3, maxConcurrency 1, where each task returns a + // verdict. The custom predicate inspects SUCCEEDED items' RESULTS (not just counts) and stops the moment any + // task's verdict is REJECT -- something no threshold config can express, since thresholds only ever see + // aggregate counts. r2 rejects, so r3 must never run. + var config = DagConfig.builder() + .maxConcurrency(1) + .completionConfig(DagCompletionConfig.custom(status -> { + boolean anyRejected = status.items().stream() + .anyMatch(item -> item.status().isPresent() + && item.status().get() == TaskStatus.SUCCEEDED + && item.result().isPresent() + && "REJECT".equals(item.result().get())); + return anyRejected + ? software.amazon.lambda.durable.dag.DagCompletionDecision.complete( + software.amazon.lambda.durable.dag.DagCompletionOutcome.FAILED) + : software.amazon.lambda.durable.dag.DagCompletionDecision.continueDag(); + })) + .build(); + var ran = new java.util.concurrent.ConcurrentSkipListSet(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "rules-engine", + d -> { + var r1 = d.step("r1", String.class, (deps, s) -> { + ran.add("r1"); + return "ACCEPT"; + }); + var r2 = d.step("r2", String.class, (deps, s) -> { + ran.add("r2"); + return "REJECT"; + }) + .reads(r1); + d.step("r3", String.class, (deps, s) -> { + ran.add("r3"); + return "ACCEPT"; + }) + .reads(r2); + }, + config); + return r.completionReason().name() + "|" + r.successCount(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals(DagCompletionReason.CUSTOM_COMPLETION_FAILED.name() + "|2", result.getResult(String.class)); + assertTrue(ran.contains("r1")); + assertTrue(ran.contains("r2")); + assertTrue( + !ran.contains("r3"), + "r3 must not run: the custom predicate should have stopped the DAG after r2's REJECT verdict"); + } + + @Test + void customCompletionFailedThrowsFromThrowIfErrorEvenWithZeroTaskFailures() { + // CUSTOM_COMPLETION_FAILED means the DAG failed by the predicate's verdict, not because any individual + // task threw. throwIfError() must still honour that verdict -- failureCount() alone is not the contract. + var config = DagConfig.builder() + .maxConcurrency(1) + .completionConfig(DagCompletionConfig.custom(status -> { + boolean anyRejected = status.items().stream() + .anyMatch(item -> item.status().isPresent() + && item.status().get() == TaskStatus.SUCCEEDED + && item.result().isPresent() + && "REJECT".equals(item.result().get())); + return anyRejected + ? software.amazon.lambda.durable.dag.DagCompletionDecision.complete( + software.amazon.lambda.durable.dag.DagCompletionOutcome.FAILED) + : software.amazon.lambda.durable.dag.DagCompletionDecision.continueDag(); + })) + .build(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "rules-engine-throw", + d -> { + var r1 = d.step("r1", String.class, (deps, s) -> "ACCEPT"); + d.step("r2", String.class, (deps, s) -> "REJECT").reads(r1); + }, + config); + assertEquals(0, r.failureCount()); + assertEquals(DagCompletionReason.CUSTOM_COMPLETION_FAILED, r.completionReason()); + try { + r.throwIfError(); + return "no-throw"; + } catch (software.amazon.lambda.durable.dag.DagExecutionException e) { + return "threw"; + } + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("threw", result.getResult(String.class)); + } + + @Test + void customCompletionSucceedsWhenPredicateNeverRejects() { + var config = DagConfig.builder() + .completionConfig(DagCompletionConfig.custom(status -> status.completedCount() >= status.totalCount() + ? software.amazon.lambda.durable.dag.DagCompletionDecision.completeSuccessfully() + : software.amazon.lambda.durable.dag.DagCompletionDecision.continueDag())) + .build(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "all-accept", + d -> { + d.step("a", String.class, (deps, s) -> "ACCEPT"); + d.step("b", String.class, (deps, s) -> "ACCEPT"); + }, + config); + return r.completionReason().name() + "|" + r.successCount(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals(DagCompletionReason.CUSTOM_COMPLETION_SUCCEEDED.name() + "|2", result.getResult(String.class)); + } + + @Test + void customCompletionPredicateSeesAccurateLiveSnapshotAtEachSettlement() { + // The predicate must see exactly what has settled so far: unsettled tasks report an empty status, settled + // tasks report their real result/skip reason, and the aggregate counts always match the per-item list. + // Asserted by recording every snapshot the predicate observes and checking the LAST one (the one that ends + // the DAG) against the graph's known final shape: a, b succeed; c is skipped (ALL_FAILED trigger rule with + // no failed upstream); d never gets a chance to run because completion fires as soon as a and b (its only + // unblocking dependencies) are both terminal and c has resolved to SKIPPED. + var snapshots = + new java.util.concurrent.CopyOnWriteArrayList(); + var config = DagConfig.builder() + .completionConfig(DagCompletionConfig.custom(status -> { + snapshots.add(status); + return status.completedCount() >= 3 + ? software.amazon.lambda.durable.dag.DagCompletionDecision.completeSuccessfully() + : software.amazon.lambda.durable.dag.DagCompletionDecision.continueDag(); + })) + .build(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "snapshot-accuracy", + d -> { + var a = d.step("a", String.class, (deps, s) -> "A"); + var b = d.step("b", String.class, (deps, s) -> "B"); + d.step("c", String.class, (deps, s) -> "C").reads(a).triggerRule(TriggerRule.ALL_FAILED); + d.step("d", String.class, (deps, s) -> "D").reads(b); + }, + config); + return r.completionReason().name(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals(DagCompletionReason.CUSTOM_COMPLETION_SUCCEEDED.name(), result.getResult(String.class)); + assertTrue(!snapshots.isEmpty(), "the predicate must have been invoked at least once"); + var last = snapshots.get(snapshots.size() - 1); + // Aggregate counts must always agree with the per-item list, at every observed snapshot -- not just the + // last one -- since a stale/inconsistent snapshot would be a real correctness bug for a predicate that + // trusts the counts without re-deriving them from items. + for (var snap : snapshots) { + long derivedSucceeded = snap.items().stream() + .filter(i -> i.status().isPresent() && i.status().get() == TaskStatus.SUCCEEDED) + .count(); + long derivedSkipped = snap.items().stream() + .filter(i -> i.status().isPresent() && i.status().get() == TaskStatus.SKIPPED) + .count(); + assertEquals(derivedSucceeded, snap.successCount()); + assertEquals(derivedSkipped, snap.skippedCount()); + assertEquals(snap.items().size(), snap.results().size()); + assertEquals(4, snap.totalCount()); + } + assertEquals(2, last.successCount(), "a and b succeed"); + assertEquals(1, last.skippedCount(), "c is skipped: ALL_FAILED with no failed upstream"); + assertTrue( + last.results().get("c").skipReason().isPresent(), + "c's snapshot entry must carry its skip reason once settled"); + } + + @Test + void maxConcurrencyThrottlesConcurrentTasks() { + var active = new java.util.concurrent.atomic.AtomicInteger(0); + var maxObserved = new java.util.concurrent.atomic.AtomicInteger(0); + var config = DagConfig.builder().maxConcurrency(2).build(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "throttle", + d -> { + for (int i = 0; i < 4; i++) { + d.step("t" + i, String.class, (deps, s) -> { + int now = active.incrementAndGet(); + maxObserved.accumulateAndGet(now, Math::max); + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + active.decrementAndGet(); + return "ok"; + }); + } + }, + config); + return r.successCount() + "|" + maxObserved.get(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + // All four tasks succeed, and observed concurrency never exceeds the cap of 2. + String[] parts = result.getResult(String.class).split("\\|"); + assertEquals(4, Integer.parseInt(parts[0])); + int observed = Integer.parseInt(parts[1]); + org.junit.jupiter.api.Assertions.assertTrue( + observed >= 1 && observed <= 2, "observed concurrency must be within [1,2] but was " + observed); + } + + @Test + void unsetMaxConcurrencyCapsWideGraphAtDefault() { + // Contract H2: with no maxConcurrency set, the DAG scheduler caps top-level concurrency at + // DagExecutor.DEFAULT_MAX_CONCURRENCY (40) — it was previously unbounded. This is the test that actually + // pins the behaviour: it asserts an OBSERVED peak via atomics, not a config value. The graph is WIDER than + // the cap (60 independent tasks all ready at once), so an unbounded default would drive peak toward 60 and + // fail the upper bound; a serialised scheduler would keep peak at 1 and fail the lower bound. Only a genuine + // cap of 40 satisfies both. + final int fanOut = 60; // > DEFAULT_MAX_CONCURRENCY (40) + final var active = new java.util.concurrent.atomic.AtomicInteger(0); + final var peak = new java.util.concurrent.atomic.AtomicInteger(0); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("wide", d -> { + for (int i = 0; i < fanOut; i++) { + d.step("t" + i, String.class, (deps, s) -> { + int now = active.incrementAndGet(); + peak.accumulateAndGet(now, Math::max); + try { + Thread.sleep(150); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + active.decrementAndGet(); + } + return "ok"; + }); + } + }); // no DagConfig -> default maxConcurrency applies + return Integer.toString(r.successCount()); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals(Integer.toString(fanOut), result.getResult(String.class), "every wide task must succeed"); + + int observedPeak = peak.get(); + assertTrue( + observedPeak <= DagExecutor.DEFAULT_MAX_CONCURRENCY, + "observed peak " + observedPeak + " must never exceed the default cap of " + + DagExecutor.DEFAULT_MAX_CONCURRENCY); + assertTrue( + observedPeak > DagExecutor.DEFAULT_MAX_CONCURRENCY / 2, + "observed peak " + observedPeak + " should climb near the cap (real overlap up to the bound), " + + "proving the scheduler is not serialising"); + } + + @Test + void explicitMaxConcurrencyAboveDefaultStillWins() { + // An explicit maxConcurrency ABOVE the default must win: the 40-task default cap must not clamp it. With 60 + // ready tasks and an explicit cap of 50, observed peak must exceed the default (proving 40 is not applied) + // while staying within the explicit bound. (The below-default case is covered by + // maxConcurrencyThrottlesConcurrentTasks, cap 2.) + final int fanOut = 60; + final int explicit = 50; // > DEFAULT_MAX_CONCURRENCY (40) + final var active = new java.util.concurrent.atomic.AtomicInteger(0); + final var peak = new java.util.concurrent.atomic.AtomicInteger(0); + var config = DagConfig.builder().maxConcurrency(explicit).build(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "wideExplicit", + d -> { + for (int i = 0; i < fanOut; i++) { + d.step("t" + i, String.class, (deps, s) -> { + int now = active.incrementAndGet(); + peak.accumulateAndGet(now, Math::max); + try { + Thread.sleep(150); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + active.decrementAndGet(); + } + return "ok"; + }); + } + }, + config); + return Integer.toString(r.successCount()); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals(Integer.toString(fanOut), result.getResult(String.class)); + + int observedPeak = peak.get(); + assertTrue( + observedPeak > DagExecutor.DEFAULT_MAX_CONCURRENCY, + "explicit maxConcurrency=" + explicit + " must win over the default cap of " + + DagExecutor.DEFAULT_MAX_CONCURRENCY + "; observed peak " + observedPeak); + assertTrue( + observedPeak <= explicit, + "observed peak " + observedPeak + " must not exceed the explicit cap " + explicit); + } + + @Test + void diamondWithWaitReplaysDeterministically() { + var aRuns = new java.util.concurrent.atomic.AtomicInteger(0); + var bRuns = new java.util.concurrent.atomic.AtomicInteger(0); + var cRuns = new java.util.concurrent.atomic.AtomicInteger(0); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("diamond", d -> { + var a = d.step("a", String.class, (deps, s) -> { + aRuns.incrementAndGet(); + return "A"; + }); + var b = d.step("b", String.class, (deps, s) -> { + bRuns.incrementAndGet(); + return deps.get(a).orElseThrow() + "B"; + }) + .reads(a); + var c = d.step("c", String.class, (deps, s) -> { + cRuns.incrementAndGet(); + return deps.get(a).orElseThrow() + "C"; + }) + .reads(a); + // Wait after the concurrent fan-out forces a suspend/replay before the join runs. + var w = d.wait("w", java.time.Duration.ofMinutes(5)).after(b, c); + d.step( + "join", + String.class, + (deps, s) -> + deps.get(b).orElseThrow() + deps.get(c).orElseThrow()) + .reads(b, c) + .after(w); + }); + return (String) r.getResult("join").orElse("MISSING"); + }); + + var result = runner.runUntilComplete("go"); + // No NonDeterministicExecutionException despite concurrent B/C completing in arbitrary order across + // the replay boundary — name-based IDs make the join deterministic. + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("ABAC", result.getResult(String.class)); + // Each upstream ran exactly once; the post-wait replay hit their name-based fast-path. + assertEquals(1, aRuns.get()); + assertEquals(1, bRuns.get()); + assertEquals(1, cRuns.get()); + } + + @Test + void largeDagResultReExecutesOnReplayWithoutRerunningTasks() { + int size = 300 * 1024; // > 256KB LARGE_RESULT_THRESHOLD for the DAG's child-context aggregate + var bigRuns = new java.util.concurrent.atomic.AtomicInteger(0); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("big", d -> { + d.step("payload", String.class, (deps, s) -> { + bigRuns.incrementAndGet(); + return "x".repeat(size); + }); + }); + int len = ((String) r.getResult("payload").orElse("")).length(); + // Wait AFTER the DAG completes forces the completed (large) DAG child to be replayed: its aggregate + // was checkpointed as an empty payload + replayChildren=true, so on resume the child body re-runs + // the scheduler and each task returns via its per-task checkpoint fast-path (no body re-execution). + ctx.wait("after", java.time.Duration.ofMinutes(5)); + return len + "|" + bigRuns.get(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + // Aggregate reconstructed to full size, and the task body executed exactly once across the replay. + assertEquals(size + "|1", result.getResult(String.class)); + } + + @Test + void wideFanOutTasksAlwaysObserveUpstreamValue() { + // B1 regression. Many tasks read a common upstream under unbounded concurrency. Each reader repeatedly reads + // the upstream via deps.get(...) while the scheduler thread concurrently records the OTHER readers' results + // with results.put(...). Pre-fix, every reader shared the scheduler's live LinkedHashMap; a get() overlapping + // a put()-induced table resize could observe a half-linked bucket and return null for the SUCCEEDED upstream — + // a silently-wrong input indistinguishable from a legitimate non-ALL_SUCCESS null. With the immutable per-task + // snapshot each reader sees a private, stable view and MUST always observe the real value. A raced null/wrong + // read throws, failing that reader task, so any occurrence surfaces as failureCount > 0. + // + // Sensitivity: the scheduler harvests futures in launch order (blocking on each get()), so results.put(...) + // calls happen roughly as tasks finish. Reader work therefore INCREASES with index so completions — and thus + // the resize-inducing puts (the 13th/25th/49th insertions grow a default-capacity map) — land while the many + // slower, later readers are still mid-loop on get(). That overlap is what makes the race observable; with + // uniform durations the writes burst after every reader has already stopped reading and nothing overlaps. + final int fanOut = 64; // >= 32; forces map growth/resizes (thresholds at 12/24/48 entries) + final int readUnit = 6000; // reader i performs (i+1) * readUnit reads: staggered, increasing durations + final int iterations = 50; // repeat so the timing-dependent race is meaningfully likely to surface + + for (int iter = 0; iter < iterations; iter++) { + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("fanout", d -> { + var up = d.step("up", String.class, (deps, s) -> "UPSTREAM"); + for (int i = 0; i < fanOut; i++) { + final int reads = (i + 1) * readUnit; + d.step("t" + i, Boolean.class, (deps, s) -> { + for (int k = 0; k < reads; k++) { + Object v = deps.get(up).orElse(null); + if (!"UPSTREAM".equals(v)) { + throw new IllegalStateException( + "raced read of upstream: expected 'UPSTREAM' but observed " + v); + } + } + return Boolean.TRUE; + }) + .reads(up); + } + }); + return r.successCount() + "|" + r.failureCount(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + // upstream (1) + every fan-out reader succeed, with ZERO failures. A single raced read flips a reader to + // FAILED and makes failureCount non-zero. + assertEquals((fanOut + 1) + "|0", result.getResult(String.class), "iteration " + iter); + } + } + + @Test + void concurrentOverlapRunsTasksInParallelWithNameBasedIds() { + // 10-13: real overlap inside one invocation (maxConcurrency unset). slow (~2s) and fast (~200ms) both depend + // on root and launch in the same wave; afterFast becomes ready before afterSlow (inverted vs registration + // order), so tasks finish OUT of registration order. We assert only order-invariant outcomes, plus the two + // things the cloud suite deliberately cannot check: (1) genuine overlap via an atomic peak counter, and + // (2) that each task's recorded operation id is its NAME-derived DAG_NODE_T_ id. A counter-based-id + // regression cannot survive the out-of-order completion (replay-consistency failure) AND would fail the id + // equality below. + final java.util.concurrent.atomic.AtomicInteger active = new java.util.concurrent.atomic.AtomicInteger(0); + final java.util.concurrent.atomic.AtomicInteger peak = new java.util.concurrent.atomic.AtomicInteger(0); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("overlapdag", d -> { + var root = d.step("root", Integer.class, (deps, s) -> 1); + var slow = d.step("slow", String.class, (deps, s) -> { + int now = active.incrementAndGet(); + peak.accumulateAndGet(now, Math::max); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + active.decrementAndGet(); + } + return "S"; + }) + .after(root); + var fast = d.step("fast", String.class, (deps, s) -> { + int now = active.incrementAndGet(); + peak.accumulateAndGet(now, Math::max); + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + active.decrementAndGet(); + } + return "F"; + }) + .after(root); + var afterSlow = d.step( + "afterSlow", + String.class, + (deps, s) -> deps.get(slow).orElseThrow() + "s") + .reads(slow); + var afterFast = d.step( + "afterFast", + String.class, + (deps, s) -> deps.get(fast).orElseThrow() + "f") + .reads(fast); + d.step( + "merge", + String.class, + (deps, s) -> deps.get(afterSlow).orElseThrow() + + deps.get(afterFast).orElseThrow()) + .reads(afterSlow, afterFast); + }); + return (String) r.getResult("merge").orElse("MISSING"); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("SsFf", result.getResult(String.class)); + + // Genuine overlap actually occurred: slow holds for ~2s while fast (~200ms) runs, so both bodies are active + // simultaneously. If a future change serialised the scheduler, peak would drop to 1 and this would fail. + assertTrue(peak.get() >= 2, "expected real overlap (peak >= 2) but observed " + peak.get()); + + // Each task's recorded operation id is its NAME-derived DAG_NODE_T_ id: hash(containerCtxId + "-DAG_NODE_T_" + // + name). Java hashes operation ids, so the id cannot literally contain the segment — the faithful check is + // equality against the recomputed name-based hash, which a counter-based regression would not match. The + // container context id is the DAG child-context op id, which is also each flat task op's parentId. + String containerId = result.getOperation("overlapdag").getId(); + for (String name : new String[] {"root", "slow", "fast", "afterSlow", "afterFast", "merge"}) { + var op = result.getOperation(name); + assertNotNull(op, "missing operation for task " + name); + String expectedId = + OperationIdGenerator.hashOperationId(containerId + "-" + DagExecutor.NODE_PREFIX + name); + assertEquals(expectedId, op.getId(), "task " + name + " must carry its own name-derived DAG_NODE_T_ id"); + assertEquals( + containerId, + op.getEvents().get(0).parentId(), + "task " + name + " must be checkpointed flat under the DAG container"); + } + } + + @Test + void invertedReadinessAcrossSuspendReplaysWithoutError() { + // 10-14: two in-flight waits (slow 8s, fast 2s) both start in the first invocation, so the invocation + // suspends with two tasks in flight and resumes twice. afterFast becomes ready one invocation before + // afterSlow, so the downstream pair starts in the REVERSE of registration order across different + // invocations — the replay-flip case. Name-based ids make this deterministic: no NonDeterministic / + // replay-consistency error, each downstream step runs exactly once, and merge fans in to "SF". + var afterSlowRuns = new java.util.concurrent.atomic.AtomicInteger(0); + var afterFastRuns = new java.util.concurrent.atomic.AtomicInteger(0); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag("suspenddag", d -> { + var root = d.step("root", Integer.class, (deps, s) -> 1); + var slow = d.wait("slow", java.time.Duration.ofSeconds(8)).after(root); + var fast = d.wait("fast", java.time.Duration.ofSeconds(2)).after(root); + var afterSlow = d.step("afterSlow", String.class, (deps, s) -> { + afterSlowRuns.incrementAndGet(); + return "S"; + }) + .after(slow); + var afterFast = d.step("afterFast", String.class, (deps, s) -> { + afterFastRuns.incrementAndGet(); + return "F"; + }) + .after(fast); + d.step( + "merge", + String.class, + (deps, s) -> deps.get(afterSlow).orElseThrow() + + deps.get(afterFast).orElseThrow()) + .reads(afterSlow, afterFast); + }); + return r.getResult("merge").map(Object::toString).orElse("MISSING") + + "|" + r.successCount() + + "|" + r.completionReason().name(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + // merge = "SF", all six tasks succeed, DAG completes normally despite the mid-graph suspend with two + // concurrent in-flight waits. + assertEquals("SF|6|" + DagCompletionReason.ALL_COMPLETED.name(), result.getResult(String.class)); + // Each downstream step ran exactly once across the suspend/replay boundary (name-based fast path); a + // re-execution would signal a replay-consistency problem. + assertEquals(1, afterSlowRuns.get()); + assertEquals(1, afterFastRuns.get()); + } + + @Test + void abortGraphFailsWithTypedErrorAndRunsNoCompensationBody() { + // 10-12 graph: a throwing runIf ABORTS the DAG. Beyond the wire/no-terminal-state facts, this asserts via an + // EXTERNAL COUNTER that the ALL_FAILED compensation body was never invoked — a predicate defect must not + // drive compensation. The top-level execution FAILS with the typed DagPredicateException naming the task. + var refundBodyRuns = new java.util.concurrent.atomic.AtomicInteger(0); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "abortdag", + d -> { + var gate = d.step("gate", Integer.class, (deps, s) -> 1); + var guarded = d.step("guarded", String.class, (deps, s) -> "ran") + .reads(gate) + .runIf(deps -> { + throw new IllegalStateException("predicate boom"); + }); + d.step("refund", String.class, (deps, s) -> { + refundBodyRuns.incrementAndGet(); + return "refunded"; + }) + .after(guarded) + .triggerRule(TriggerRule.ALL_FAILED); + }, + DagConfig.builder().maxConcurrency(1).build()); + return "unreached:" + r.completionReason().name(); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.FAILED, result.getStatus()); + + // The DAG container checkpointed the typed abort error, naming the offending task and the original error. + var container = result.getOperation("abortdag"); + assertEquals(OperationStatus.FAILED, container.getStatus()); + var error = container.getContextDetails().error(); + assertNotNull(error, "DAG container must checkpoint the failure error"); + assertEquals("software.amazon.lambda.durable.dag.DagPredicateException", error.errorType()); + assertTrue( + error.errorMessage().contains("guarded"), + "message must name the offending task: " + error.errorMessage()); + assertTrue( + error.errorMessage().contains("predicate boom"), + "message must identify the original error: " + error.errorMessage()); + + // gate succeeded; guarded (offending) and refund (compensation) have NO terminal state; and, crucially, the + // compensation body never executed. + assertEquals(OperationStatus.SUCCEEDED, result.getOperation("gate").getStatus()); + assertNull(result.getOperation("guarded"), "offending task must have no terminal state"); + assertNull(result.getOperation("refund"), "downstream ALL_FAILED compensation must not run"); + assertEquals(0, refundBodyRuns.get(), "ALL_FAILED compensation body must never be invoked"); + } + + @Test + void largePayloadAggregateSurvivesContainerReplayByteIdentical() { + // 10-15 (shared aggregate fidelity). Eight roots p1..p8 each return their own letter × 51200 (a..h), so the + // aggregate is ~410KB — comfortably over the 256KB checkpoint threshold — while every individual result stays + // well under it, so ONLY the aggregate is offloaded. A wait AFTER the DAG resolves forces the next invocation + // to replay the completed (offloaded) container. We assert (a) the offload actually fired (the container + // checkpoint carries replayChildren=true; without this the test would not exercise the large-payload path), + // and (b) every task result is individually retrievable and BYTE-IDENTICAL after the replay — checking full + // 51200-char values, not just a digest. + final int perTask = 51200; + final var replayed = new java.util.concurrent.ConcurrentHashMap(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "bigdag", + d -> { + for (int i = 1; i <= 8; i++) { + final String letter = String.valueOf((char) ('a' + (i - 1))); + d.step("p" + i, String.class, (deps, s) -> letter.repeat(perTask)); + } + }, + DagConfig.builder().maxConcurrency(1).build()); + // Suspend AFTER the DAG completes → the completed, offloaded container is replayed on resume. This code + // runs only in the resume invocation, so it captures the REPLAYED per-task values. + ctx.wait("suspend", java.time.Duration.ofMinutes(5)); + for (int i = 1; i <= 8; i++) { + replayed.put("p" + i, (String) r.getResult("p" + i).orElseThrow()); + } + return Integer.toString(r.successCount()); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("8", result.getResult(String.class)); + + // The offload actually triggered: the DAG container was checkpointed with an empty payload + ReplayChildren + // because its aggregate exceeded 256KB. This is the direct evidence the large-payload path was exercised. + var container = result.getOperation("bigdag"); + assertNotNull(container, "DAG container operation must exist"); + assertEquals( + Boolean.TRUE, + container.getContextDetails().replayChildren(), + "aggregate must exceed 256KB and be offloaded (replayChildren=true)"); + + // Every task result round-tripped byte-identical through the offload + replay, at full 51200-char length. + assertEquals(8, replayed.size()); + for (int i = 1; i <= 8; i++) { + String expected = String.valueOf((char) ('a' + (i - 1))).repeat(perTask); + String actual = replayed.get("p" + i); + assertEquals(perTask, actual.length(), "task p" + i + " must retain its full length after replay"); + assertEquals(expected, actual, "task p" + i + " must round-trip byte-identical after replay"); + } + } + + @Test + void largePayloadTaskBodiesRunExactlyOnceAcrossOffloadAndReplay() { + // 10-15 (shared exactly-once). External per-task counters. The container is offloaded (aggregate > 256KB) and + // replayed after the wait. Under Java's re-execution strategy the scheduler re-runs on resume, but each task + // body MUST fast-path from its own per-task checkpoint. If a body runs twice, a customer's side effect happens + // twice — the bug this test exists to catch. Assert every body ran EXACTLY ONCE across the offload and replay. + final int perTask = 51200; + final java.util.concurrent.atomic.AtomicInteger[] runs = new java.util.concurrent.atomic.AtomicInteger[8]; + for (int i = 0; i < 8; i++) { + runs[i] = new java.util.concurrent.atomic.AtomicInteger(0); + } + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "bigdag", + d -> { + for (int i = 1; i <= 8; i++) { + final int idx = i - 1; + final String letter = String.valueOf((char) ('a' + idx)); + d.step("p" + i, String.class, (deps, s) -> { + runs[idx].incrementAndGet(); + return letter.repeat(perTask); + }); + } + }, + DagConfig.builder().maxConcurrency(1).build()); + ctx.wait("suspend", java.time.Duration.ofMinutes(5)); + return Integer.toString(r.successCount()); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("8", result.getResult(String.class)); + // The replay path was genuinely exercised (offload fired) ... + assertEquals( + Boolean.TRUE, + result.getOperation("bigdag").getContextDetails().replayChildren(), + "aggregate must be offloaded (replayChildren=true) for this test to exercise the replay path"); + // ... and every task body ran exactly once despite the container replay (per-task checkpoint fast-path). + for (int i = 0; i < 8; i++) { + assertEquals(1, runs[i].get(), "task p" + (i + 1) + " body must run exactly once across offload + replay"); + } + } + + @Test + void largePayloadContainerReplayUsesChildBodyReExecutionNotEnvelope() { + // 10-15 (Java re-execution path). Java has NO summary-generator hook (DAG_SPEC_CROSS_LANGUAGE §2.B.6): unlike + // TypeScript, which writes an SDK-owned DagSummary envelope and reconstructs the aggregate from it, Java + // re-executes the DAG child body via ReplayChildren and rebuilds the aggregate from the per-task checkpoints, + // exactly as map does. The hook the SDK exposes for "which path was taken" is the container's ReplayChildren + // flag: replayChildren=true means the empty-payload + re-execute-children strategy — NOT envelope + // reconstruction (there is no envelope in Java). We assert that flag is set, that the per-task checkpoints the + // re-execution rebuilds from are present and SUCCEEDED, and that the reconstructed per-task results are + // identical — the same fidelity guarantee as JS reached by a different mechanism. + final int perTask = 51200; + final var replayed = new java.util.concurrent.ConcurrentHashMap(); + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "bigdag", + d -> { + for (int i = 1; i <= 8; i++) { + final String letter = String.valueOf((char) ('a' + (i - 1))); + d.step("p" + i, String.class, (deps, s) -> letter.repeat(perTask)); + } + }, + DagConfig.builder().maxConcurrency(1).build()); + ctx.wait("suspend", java.time.Duration.ofMinutes(5)); + for (int i = 1; i <= 8; i++) { + replayed.put("p" + i, (String) r.getResult("p" + i).orElseThrow()); + } + return Integer.toString(r.successCount()); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + // Mechanism: the container took the ReplayChildren (re-execute) path, not envelope reconstruction. This is the + // observable hook Java exposes for the large-payload replay strategy. + var container = result.getOperation("bigdag"); + assertEquals( + Boolean.TRUE, + container.getContextDetails().replayChildren(), + "Java large-payload replay must use the ReplayChildren re-execution strategy (no DagSummary envelope)"); + + // The re-execution rebuilds the aggregate from per-task checkpoints: each task is a flat, SUCCEEDED operation + // under the container, and its reconstructed result is byte-identical — fidelity by re-execution, not envelope. + for (int i = 1; i <= 8; i++) { + var taskOp = result.getOperation("p" + i); + assertNotNull(taskOp, "per-task checkpoint p" + i + " must exist for re-execution to rebuild from"); + assertEquals( + OperationStatus.SUCCEEDED, taskOp.getStatus(), "per-task checkpoint p" + i + " must be SUCCEEDED"); + String expected = String.valueOf((char) ('a' + (i - 1))).repeat(perTask); + assertEquals(expected, replayed.get("p" + i), "re-executed task p" + i + " must yield identical result"); + } + } + + @Test + void nestedDagInnerAggregateOffloadsAndSurvivesReconstruct() { + // Nested-offload contract, test 2. Outer DAG "outernested" contains a nested dag task "inner" whose OWN + // aggregate (6 × 51200 = 307200 chars ≈ 307KB) exceeds the 256KB checkpoint limit, so the inner container + // offloads; because the outer embeds the inner result in full, the outer offloads too. digestBefore/wait/ + // digestAfter are outer tasks (mirroring 10-17). The wait forces the next invocation to replay both completed, + // offloaded containers. After the reconstruct path runs, the inner DagResult read through the outer must report + // the correct counts and reason (rule 1) AND, under Java's re-execution reconstruct, its full per-task detail + // (rule 2), so the two digests are byte-equal. + final int perTask = 51200; // 6 × 51200 = 307200 > 256KB + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "outernested", + d -> { + var inner = d.dag( + "inner", + nd -> { + for (int i = 1; i <= 6; i++) { + final String letter = String.valueOf((char) ('a' + (i - 1))); + nd.step("p" + i, String.class, (deps, s) -> letter.repeat(perTask)); + } + }, + DagConfig.builder().maxConcurrency(1).build()); + var digestBefore = d.step( + "digestBefore", + String.class, + (deps, s) -> innerDigest( + (DagResult) deps.get(inner).orElseThrow())) + .reads(inner); + var w = d.wait("wait", java.time.Duration.ofSeconds(2)).after(digestBefore); + d.step( + "digestAfter", + String.class, + (deps, s) -> innerDigest( + (DagResult) deps.get(inner).orElseThrow())) + .reads(inner) + .after(w); + }, + DagConfig.builder().maxConcurrency(1).build()); + + DagResult inner = (DagResult) r.getResult("inner").orElseThrow(); + String digestBefore = (String) r.getResult("digestBefore").orElseThrow(); + String digestAfter = (String) r.getResult("digestAfter").orElseThrow(); + return digestBefore + "#" + digestAfter + "#" + + inner.completionReason().name() + "#" + inner.totalCount() + + "," + inner.failureCount() + "," + inner.skippedCount() + "," + inner.successCount() + "#" + + digestBefore.equals(digestAfter); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + // digestBefore == digestAfter == "6:307200:abcdef"; inner ALL_COMPLETED; innerCounts [total,failed,skipped, + // succeeded] = [6,0,0,6]; match=true. The decisive proof the inner per-task detail survived both offloads. + assertEquals( + "6:307200:abcdef#6:307200:abcdef#" + DagCompletionReason.ALL_COMPLETED.name() + "#6,0,0,6#true", + result.getResult(String.class)); + + // Both containers actually offloaded (aggregate > 256KB → empty payload + ReplayChildren), so the reconstruct + // path was genuinely exercised for the nested case. + assertEquals( + Boolean.TRUE, + result.getOperation("inner").getContextDetails().replayChildren(), + "inner nested-dag container must be offloaded (replayChildren=true)"); + assertEquals( + Boolean.TRUE, + result.getOperation("outernested").getContextDetails().replayChildren(), + "outer dag container must be offloaded (replayChildren=true)"); + } + + @Test + void nestedDagOffloadTaskBodiesRunExactlyOnce() { + // Nested-offload contract, test 3. Nesting doubles the number of containers that replay, so assert with + // per-task counters that each inner task body runs EXACTLY ONCE across the offloaded replay of both the inner + // and the outer container. A body running twice would double a customer side effect. + final int perTask = 51200; + final java.util.concurrent.atomic.AtomicInteger[] runs = new java.util.concurrent.atomic.AtomicInteger[6]; + for (int i = 0; i < 6; i++) { + runs[i] = new java.util.concurrent.atomic.AtomicInteger(0); + } + var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> { + DagResult r = dag( + "outernested", + d -> { + var inner = d.dag( + "inner", + nd -> { + for (int i = 1; i <= 6; i++) { + final int idx = i - 1; + final String letter = String.valueOf((char) ('a' + idx)); + nd.step("p" + i, String.class, (deps, s) -> { + runs[idx].incrementAndGet(); + return letter.repeat(perTask); + }); + } + }, + DagConfig.builder().maxConcurrency(1).build()); + var digestBefore = d.step( + "digestBefore", + String.class, + (deps, s) -> innerDigest( + (DagResult) deps.get(inner).orElseThrow())) + .reads(inner); + var w = d.wait("wait", java.time.Duration.ofSeconds(2)).after(digestBefore); + d.step( + "digestAfter", + String.class, + (deps, s) -> innerDigest( + (DagResult) deps.get(inner).orElseThrow())) + .reads(inner) + .after(w); + }, + DagConfig.builder().maxConcurrency(1).build()); + return Integer.toString(((DagResult) r.getResult("inner").orElseThrow()).successCount()); + }); + + var result = runner.runUntilComplete("go"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("6", result.getResult(String.class)); + // The replay path was genuinely exercised (both containers offloaded) ... + assertEquals( + Boolean.TRUE, + result.getOperation("inner").getContextDetails().replayChildren(), + "inner container must be offloaded for this test to exercise the nested replay path"); + // ... and every inner task body ran exactly once despite the inner+outer container re-execution. + for (int i = 0; i < 6; i++) { + assertEquals( + 1, + runs[i].get(), + "inner task p" + (i + 1) + " body must run exactly once across the nested offload + replay"); + } + } + + /** + * Language-neutral digest of a nested DAG's aggregate: + * {@code "::"}; for the p1..p6 graph it is + * {@code "6:307200:abcdef"}. + */ + private static String innerDigest(DagResult inner) { + long totalLength = 0; + StringBuilder firstChars = new StringBuilder(); + for (int i = 1; i <= 6; i++) { + String v = (String) inner.getResult("p" + i).orElseThrow(); + totalLength += v.length(); + firstChars.append(v.charAt(0)); + } + return inner.totalCount() + ":" + totalLength + ":" + firstChars; + } +} diff --git a/dag/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java new file mode 100644 index 000000000..ddb4364e3 --- /dev/null +++ b/dag/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.serde.JacksonSerDes; + +/** Unit tests for {@link DagExecutionException} and its cross-boundary (SerDes) reconstruction. */ +class DagExecutionExceptionTest { + + @Test + void survivesSerDesRoundTripPreservingTypeAndCause() { + // throwIfError() can be invoked inside a nested DAG task body, so a cause-carrying DagExecutionException can + // cross the child-context boundary. Reconstruction only works because of the @JsonCreator that sets the cause + // at construction (the base hierarchy pre-initializes the cause to null, so Jackson's default initCause path + // fails) — the same idiom DagPredicateException uses. Without it the type erases to + // ChildContextFailedException. + var serDes = new JacksonSerDes(); + var original = new DagExecutionException("DAG had 1 failed task", new IllegalStateException("task failed")); + + String json = serDes.serialize(original); + var restored = serDes.deserialize(json, TypeToken.get(DagExecutionException.class)); + + assertNotNull(restored); + assertEquals(original.getMessage(), restored.getMessage()); + assertNotNull(restored.getCause(), "cause must survive the round trip"); + assertEquals("task failed", restored.getCause().getMessage()); + } + + @Test + void survivesSerDesRoundTripWithoutCause() { + var serDes = new JacksonSerDes(); + var original = new DagExecutionException("DAG had 1 failed task"); + + String json = serDes.serialize(original); + var restored = serDes.deserialize(json, TypeToken.get(DagExecutionException.class)); + + assertNotNull(restored); + assertEquals(original.getMessage(), restored.getMessage()); + assertTrue(restored.getCause() == null, "no cause was set"); + } +} diff --git a/dag/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java new file mode 100644 index 000000000..f4433a0a4 --- /dev/null +++ b/dag/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.exception.DurableExecutionException; +import software.amazon.lambda.durable.serde.JacksonSerDes; + +/** Unit tests for {@link DagPredicateException} and its cross-boundary (SerDes) reconstruction. */ +class DagPredicateExceptionTest { + + @Test + void carriesTaskNameMessageAndCause() { + var cause = new IllegalStateException("predicate boom"); + var ex = new DagPredicateException("maybe", cause); + + assertEquals("maybe", ex.taskName()); + assertEquals(cause, ex.getCause()); + assertTrue(ex.getMessage().contains("maybe"), "message must name the task"); + assertTrue(ex.getMessage().contains("predicate boom"), "message must identify the original error"); + assertInstanceOf(DagException.class, ex); + assertInstanceOf(DurableExecutionException.class, ex); + } + + @Test + void survivesSerDesRoundTripPreservingTypeAndCause() { + // This is the round-trip the DAG child-context boundary relies on: the typed exception is serialized on the + // failing side and reconstructed on the caller side. The reconstructed exception must remain a + // DagPredicateException whose message and taskName name the task and whose cause is the reconstructed original + // error. + var serDes = new JacksonSerDes(); + var original = new DagPredicateException("maybe", new IllegalStateException("predicate boom")); + + String json = serDes.serialize(original); + var restored = serDes.deserialize(json, TypeToken.get(DagPredicateException.class)); + + assertNotNull(restored); + assertEquals(original.getMessage(), restored.getMessage()); + assertTrue(restored.getMessage().contains("maybe")); + assertEquals("maybe", restored.taskName()); + // The cause is retrievable with its original message and stack trace preserved. Its concrete Java type + // degrades to Throwable across the durable boundary: the SDK serializes a nested cause without polymorphic + // type info, so only a top-level exception's concrete type is recoverable (via ErrorObject.errorType). This is + // a general property of the SDK's exception serialization, not specific to runIf. + assertNotNull(restored.getCause()); + assertEquals("predicate boom", restored.getCause().getMessage()); + assertTrue(restored.getCause().getStackTrace().length > 0, "cause stack trace must be preserved"); + } +} diff --git a/dag/src/test/java/software/amazon/lambda/durable/dag/DurableDagOperationTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/DurableDagOperationTest.java new file mode 100644 index 000000000..e45e0b77b --- /dev/null +++ b/dag/src/test/java/software/amazon/lambda/durable/dag/DurableDagOperationTest.java @@ -0,0 +1,142 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.lambda.durable.DurableConfig; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.config.StepSemantics; +import software.amazon.lambda.durable.context.BaseContextImpl; +import software.amazon.lambda.durable.dag.internal.DagResultSerDes; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.extension.ExtensionContextConfig; +import software.amazon.lambda.durable.extension.ExtensionContextFunction; +import software.amazon.lambda.durable.extension.ExtensionOperation; +import software.amazon.lambda.durable.extension.ExtensionStepConfig; +import software.amazon.lambda.durable.extension.ExtensionStepFunction; +import software.amazon.lambda.durable.extension.ExtensionStepResult; +import software.amazon.lambda.durable.retry.RetryDecision; +import software.amazon.lambda.durable.serde.JacksonSerDes; + +class DurableDagOperationTest { + @Test + void dagUsesExtensionReservationsForContainerAndTasks() { + var context = mock(ExtensionContext.class); + var parent = mock(ExtensionOperation.class); + var parentFuture = mockDagFuture(); + var serDes = new JacksonSerDes(); + var retryDelay = Duration.ofSeconds(3); + var stepConfig = StepConfig.builder() + .serDes(serDes) + .retryStrategy((error, attempt) -> RetryDecision.retry(retryDelay)) + .semanticsPerRetry(StepSemantics.AT_MOST_ONCE_PER_RETRY) + .build(); + when(context.getDurableConfig()) + .thenReturn(DurableConfig.builder().withSerDes(serDes).build()); + when(context.reserve("graph")).thenReturn(parent); + when(parent.runInChildContextAsync( + eq("Dag"), + eq(TypeToken.get(DagResult.class)), + any(ExtensionContextFunction.class), + any(ExtensionContextConfig.class))) + .thenReturn(parentFuture); + + DurableFuture actual; + try (var ignored = BaseContextImpl.attachCurrentContext(context)) { + actual = DurableDagOperation.dagAsync( + "graph", dag -> dag.step("node", String.class, (deps, step) -> "done", stepConfig)); + } + + assertSame(parentFuture, actual); + var function = extensionFunction(); + var extensionConfig = ArgumentCaptor.forClass(ExtensionContextConfig.class); + verify(parent) + .runInChildContextAsync( + eq("Dag"), eq(TypeToken.get(DagResult.class)), function.capture(), extensionConfig.capture()); + assertInstanceOf(DagResultSerDes.class, extensionConfig.getValue().serDes()); + verify(context).reserve("graph"); + + var child = mock(ExtensionContext.class); + var task = mock(ExtensionOperation.class); + var taskConfig = ArgumentCaptor.forClass(ExtensionStepConfig.class); + when(child.reserve("node", "DAG_NODE_T_node")).thenReturn(task); + when(task.stepAsync( + eq("Step"), + eq(TypeToken.get(String.class)), + any(ExtensionStepFunction.class), + taskConfig.capture())) + .thenReturn(new CompletedFuture<>("done")); + + DagResult result; + try (var ignored = BaseContextImpl.attachCurrentContext(child)) { + result = function.getValue().apply().result(); + } + + assertEquals("done", result.getResult("node").orElseThrow()); + assertSame(serDes, taskConfig.getValue().serDes()); + assertEquals( + ExtensionStepConfig.StepSemantics.AT_MOST_ONCE_PER_RETRY, + taskConfig.getValue().semanticsPerRetry()); + var retryDecision = + taskConfig.getValue().retryStrategy().makeRetryDecision(new IllegalStateException("retry"), "state", 1); + var retry = assertInstanceOf(ExtensionStepResult.Retry.class, retryDecision); + assertEquals("state", retry.state()); + assertEquals(retryDelay, retry.delay()); + verify(child).reserve("node", "DAG_NODE_T_node"); + verify(child, never()).reserve("node"); + } + + @Test + void invalidNameFailsBeforeRegistration() { + var context = mock(ExtensionContext.class); + var registrationCalled = new AtomicBoolean(); + + try (var ignored = BaseContextImpl.attachCurrentContext(context)) { + assertThrows( + IllegalArgumentException.class, + () -> DurableDagOperation.dagAsync("", dag -> registrationCalled.set(true))); + } + + assertEquals(false, registrationCalled.get()); + verify(context, never()).reserve(any()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private ArgumentCaptor> extensionFunction() { + return (ArgumentCaptor) ArgumentCaptor.forClass(ExtensionContextFunction.class); + } + + @SuppressWarnings("unchecked") + private DurableFuture mockDagFuture() { + return mock(DurableFuture.class); + } + + private record CompletedFuture(T result) implements DurableFuture { + @Override + public T get() { + return result; + } + + @Override + public CompletableFuture completionFuture() { + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java new file mode 100644 index 000000000..bc0513f3e --- /dev/null +++ b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java @@ -0,0 +1,255 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.dag.DagCompletionReason; +import software.amazon.lambda.durable.dag.DagExecutionException; +import software.amazon.lambda.durable.dag.DagResult; +import software.amazon.lambda.durable.dag.DagTaskError; +import software.amazon.lambda.durable.dag.SkipReason; +import software.amazon.lambda.durable.dag.TaskExecution; +import software.amazon.lambda.durable.dag.TaskStatus; +import software.amazon.lambda.durable.serde.JacksonSerDes; + +/** + * Pins the cross-language DAG container envelope produced by the Java SDK, in both the inline (with {@code tasks}) and + * offloaded (without {@code tasks}) cases, for a three-task DAG with one success, one failure and one skip. Also pins + * the lowercase {@code resultKind} vocabulary, the PascalCase error object, and additive-only unknown-field tolerance. + */ +class DagEnvelopeConvergenceTest { + + private static final Path OUT_DIR = + Path.of(System.getProperty("dag.envelope.outputDir", "target/dag-envelope-out")); + + private static final Instant T0 = Instant.parse("2026-07-26T03:19:01.884Z"); + private static final Instant T1 = Instant.parse("2026-07-26T03:19:01.885Z"); + private static final Instant T2 = Instant.parse("2026-07-26T03:19:02.010Z"); + private static final Instant T3 = Instant.parse("2026-07-26T03:19:02.140Z"); + + /** The canonical three-task DAG: load-order SUCCEEDED, charge-card FAILED, ship SKIPPED. */ + private static DagResult threeTaskDag() { + Map> m = new LinkedHashMap<>(); + m.put( + "load-order", + new TaskExecution<>( + "load-order", + TaskStatus.SUCCEEDED, + Optional.empty(), + Optional.of("order-42"), + Optional.empty(), + Optional.of(T0), + Optional.of(T1))); + m.put( + "charge-card", + new TaskExecution<>( + "charge-card", + TaskStatus.FAILED, + Optional.empty(), + Optional.empty(), + Optional.of(new DagTaskError( + "java.lang.RuntimeException", + "card declined", + List.of( + "com.example.Orders.charge(Orders.java:42)", + "com.example.Orders.run(Orders.java:17)"))), + Optional.of(T2), + Optional.of(T3))); + m.put( + "ship", + new TaskExecution<>( + "ship", + TaskStatus.SKIPPED, + Optional.of(SkipReason.TRIGGER_RULE), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty())); + return new DagResultImpl(m, DagCompletionReason.COMPLETED_WITH_FAILURES, 3, List.of()); + } + + @Test + void inlineEnvelopePinsExactShape() throws Exception { + var serdes = new DagResultSerDes(new JacksonSerDes()); + String json = serdes.serialize(threeTaskDag()); + writeArtifact("inline.json", json); + + var root = new ObjectMapper().readTree(json); + assertEquals("DagResult", root.get("type").asText()); + assertEquals(3, root.get("totalCount").asInt()); + assertEquals(1, root.get("successCount").asInt()); + assertEquals(1, root.get("failureCount").asInt()); + assertEquals(1, root.get("skippedCount").asInt()); + assertEquals("COMPLETED_WITH_FAILURES", root.get("completionReason").asText()); + assertTrue(root.get("startedTaskNames").isArray() + && root.get("startedTaskNames").isEmpty()); + assertEquals(1, root.get("failedTaskNames").size()); + assertEquals("charge-card", root.get("failedTaskNames").get(0).asText()); + assertTrue(root.has("tasks"), "inline envelope must carry tasks"); + assertEquals(3, root.get("tasks").size()); + + var load = root.get("tasks").get(0); + assertEquals("load-order", load.get("name").asText()); + assertEquals("SUCCEEDED", load.get("status").asText()); + assertTrue(load.get("skipReason").isNull()); + assertEquals("plain", load.get("resultKind").asText()); + assertEquals("order-42", load.get("result").asText()); + assertTrue(load.get("error").isNull()); + assertEquals("2026-07-26T03:19:01.884Z", load.get("startedAt").asText()); + assertEquals("2026-07-26T03:19:01.885Z", load.get("completedAt").asText()); + + var charge = root.get("tasks").get(1); + assertEquals("charge-card", charge.get("name").asText()); + assertEquals("FAILED", charge.get("status").asText()); + // resultKind is null when there is no result to interpret, and the KEY is still + // present: rule 1 says absent values are explicit null, never omitted. + assertTrue(charge.has("resultKind"), "resultKind key must be present"); + assertTrue(charge.get("resultKind").isNull(), "FAILED task carries resultKind null"); + assertTrue(charge.has("result")); + assertTrue(charge.get("result").isNull()); + // Error object is PascalCase. + var err = charge.get("error"); + assertEquals("java.lang.RuntimeException", err.get("ErrorType").asText()); + assertEquals("card declined", err.get("ErrorMessage").asText()); + assertEquals(2, err.get("StackTrace").size()); + + var ship = root.get("tasks").get(2); + assertEquals("SKIPPED", ship.get("status").asText()); + assertEquals("TRIGGER_RULE", ship.get("skipReason").asText()); + assertTrue(ship.has("resultKind"), "resultKind key must be present"); + assertTrue(ship.get("resultKind").isNull(), "SKIPPED task carries resultKind null"); + assertTrue(ship.has("error")); + assertTrue(ship.get("error").isNull()); + assertTrue(ship.get("startedAt").isNull()); + assertTrue(ship.get("completedAt").isNull()); + } + + @Test + void offloadedEnvelopeDropsTasksButKeepsAggregate() throws Exception { + var serdes = new DagResultSerDes(new JacksonSerDes()); + List ladder = serdes.offloadPayloads(threeTaskDag()); + // Step 2: tasks dropped, failedTaskNames kept. + String offloaded = ladder.get(0); + writeArtifact("offloaded.json", offloaded); + var root = new ObjectMapper().readTree(offloaded); + assertFalse(root.has("tasks"), "offloaded envelope must NOT carry tasks (absence is the signal)"); + assertEquals("DagResult", root.get("type").asText()); + assertEquals(3, root.get("totalCount").asInt()); + assertEquals(1, root.get("successCount").asInt()); + assertEquals(1, root.get("failureCount").asInt()); + assertEquals(1, root.get("skippedCount").asInt()); + assertEquals("COMPLETED_WITH_FAILURES", root.get("completionReason").asText()); + assertTrue(root.get("startedTaskNames").isArray()); + assertEquals("charge-card", root.get("failedTaskNames").get(0).asText()); + + // Step 3 (last resort): failedTaskNames also dropped; counts/reason/startedTaskNames still present. + var root2 = new ObjectMapper().readTree(ladder.get(1)); + assertFalse(root2.has("tasks")); + assertFalse(root2.has("failedTaskNames")); + assertTrue(root2.has("totalCount")); + assertTrue(root2.has("completionReason")); + assertTrue(root2.has("startedTaskNames")); + } + + @Test + void restoringOffloadedEnvelopeWithFailuresPreservesCountsAndReason() { + // Contract rule 1: restoring a tasks-less (offloaded) envelope MUST preserve totalCount, the three counts and + // completionReason. It must NEVER fabricate ALL_COMPLETED / zeroed counts when the envelope says otherwise. + var serdes = new DagResultSerDes(new JacksonSerDes()); + // Step-2 offload payload: tasks dropped, failedTaskNames kept. + String offloaded = serdes.offloadPayloads(threeTaskDag()).get(0); + + DagResult restored = serdes.deserialize(offloaded, TypeToken.get(DagResult.class)); + + // The per-task map is legitimately empty (tasks were offloaded)... + assertTrue(restored.results().isEmpty(), "offloaded restore has no per-task detail in the map"); + // ...but the aggregate is honest, NOT a fabricated empty success. + assertEquals(DagCompletionReason.COMPLETED_WITH_FAILURES, restored.completionReason()); + assertFalse( + restored.completionReason() == DagCompletionReason.ALL_COMPLETED, + "must not report ALL_COMPLETED for a DAG that had failures"); + assertEquals(3, restored.totalCount()); + assertEquals(1, restored.successCount()); + assertEquals(1, restored.failureCount()); + assertEquals(1, restored.skippedCount()); + // A caller must never be told the DAG succeeded when the checkpoint says it did not. + assertThrows(DagExecutionException.class, restored::throwIfError); + } + + @Test + void restoringLastResortOffloadEnvelopePreservesCountsAndReason() { + // The smallest offload candidate additionally drops failedTaskNames; counts, reason and totalCount must still + // survive the round-trip. + var serdes = new DagResultSerDes(new JacksonSerDes()); + String lastResort = serdes.offloadPayloads(threeTaskDag()).get(1); + + DagResult restored = serdes.deserialize(lastResort, TypeToken.get(DagResult.class)); + + assertEquals(DagCompletionReason.COMPLETED_WITH_FAILURES, restored.completionReason()); + assertEquals(3, restored.totalCount()); + assertEquals(1, restored.successCount()); + assertEquals(1, restored.failureCount()); + assertEquals(1, restored.skippedCount()); + } + + @Test + void unknownExtraFieldDeserializesWithoutError() { + // Additive-only evolution (no schemaVersion): a reader MUST ignore an unknown field rather than fail. + var serdes = new DagResultSerDes(new JacksonSerDes()); + String json = serdes.serialize(threeTaskDag()); + // Inject an unknown top-level field and an unknown per-task field. + String withExtra = json.replaceFirst("\\{", "{\"futureField\":{\"nested\":true},") + .replaceFirst("\"name\":\"load-order\"", "\"name\":\"load-order\",\"futureTaskField\":123"); + + DagResult restored = serdes.deserialize(withExtra, TypeToken.get(DagResult.class)); + assertEquals(3, restored.totalCount()); + assertEquals(1, restored.successCount()); + assertEquals(1, restored.failureCount()); + assertEquals(1, restored.skippedCount()); + assertEquals(DagCompletionReason.COMPLETED_WITH_FAILURES, restored.completionReason()); + assertEquals("order-42", restored.getResult("load-order").orElseThrow()); + assertEquals(TaskStatus.SKIPPED, restored.getStatus("ship").orElseThrow()); + } + + @Test + void inlineEnvelopeRoundTrips() { + var serdes = new DagResultSerDes(new JacksonSerDes()); + var restored = serdes.deserialize(serdes.serialize(threeTaskDag()), TypeToken.get(DagResult.class)); + assertEquals(3, restored.totalCount()); + assertEquals(1, restored.successCount()); + assertEquals(1, restored.failureCount()); + assertEquals(1, restored.skippedCount()); + assertEquals(List.of("charge-card"), restored.failedTaskNames()); + var charge = restored.failed().get(0).error().orElseThrow(); + assertEquals("java.lang.RuntimeException", charge.errorType()); + assertEquals("card declined", charge.errorMessage()); + assertEquals(2, charge.stackTrace().size()); + var load = restored.results().get("load-order"); + assertEquals(Optional.of(T0), load.startedAt()); + assertEquals(Optional.of(T1), load.completedAt()); + } + + private static void writeArtifact(String name, String json) throws Exception { + // Pretty-print for the human-readable report artifact; assertions above run on the compact production bytes. + var mapper = new ObjectMapper(); + String pretty = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(mapper.readTree(json)); + Files.createDirectories(OUT_DIR); + Files.writeString(OUT_DIR.resolve(name), pretty + "\n", StandardCharsets.UTF_8); + Files.writeString(OUT_DIR.resolve(name.replace(".json", ".compact.json")), json + "\n", StandardCharsets.UTF_8); + } +} diff --git a/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java new file mode 100644 index 000000000..f7df31850 --- /dev/null +++ b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java @@ -0,0 +1,228 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.dag.DagCompletionReason; +import software.amazon.lambda.durable.dag.DagExecutionException; +import software.amazon.lambda.durable.dag.DagTaskError; +import software.amazon.lambda.durable.dag.SkipReason; +import software.amazon.lambda.durable.dag.TaskExecution; +import software.amazon.lambda.durable.dag.TaskStatus; +import software.amazon.lambda.durable.serde.JacksonSerDes; + +class DagResultTest { + + private static TaskExecution ok(String name, Object value) { + return new TaskExecution<>( + name, + TaskStatus.SUCCEEDED, + Optional.empty(), + Optional.ofNullable(value), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + + private static TaskExecution fail(String name, DagTaskError err) { + return new TaskExecution<>( + name, + TaskStatus.FAILED, + Optional.empty(), + Optional.empty(), + Optional.of(err), + Optional.empty(), + Optional.empty()); + } + + private static TaskExecution skip(String name) { + return new TaskExecution<>( + name, + TaskStatus.SKIPPED, + Optional.of(SkipReason.TRIGGER_RULE), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + + private static DagResultImpl sample() { + Map> m = new LinkedHashMap<>(); + m.put("a", ok("a", "hello")); + m.put("b", ok("b", 42)); + m.put("c", skip("c")); + return new DagResultImpl(m, DagCompletionReason.ALL_COMPLETED); + } + + @Test + void countsAndAccessors() { + var r = sample(); + assertEquals(3, r.totalCount()); + assertEquals(2, r.successCount()); + assertEquals(1, r.skippedCount()); + assertEquals(0, r.failureCount()); + assertEquals(Optional.of("hello"), r.getResult("a")); + assertEquals(Optional.of(TaskStatus.SKIPPED), r.getStatus("c")); + assertTrue(r.getResult("c").isEmpty()); + } + + @Test + void throwIfErrorThrowsOnFailure() { + Map> m = new LinkedHashMap<>(); + m.put("a", ok("a", "x")); + m.put("bad", fail("bad", DagTaskError.of(new RuntimeException("boom")))); + var r = new DagResultImpl(m, DagCompletionReason.COMPLETED_WITH_FAILURES); + assertEquals(1, r.failureCount()); + assertThrows(DagExecutionException.class, r::throwIfError); + } + + @Test + void throwIfErrorNoopWhenClean() { + sample().throwIfError(); + } + + @Test + void serdeRoundTripPreservesStructure() { + var serdes = new DagResultSerDes(new JacksonSerDes()); + var original = sample(); + var json = serdes.serialize(original); + var restored = serdes.deserialize(json, TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + assertEquals(3, restored.totalCount()); + assertEquals(2, restored.successCount()); + assertEquals(1, restored.skippedCount()); + assertEquals(Optional.of("hello"), restored.getResult("a")); + assertEquals(42, ((Number) restored.getResult("b").orElseThrow()).intValue()); + assertEquals(Optional.of(TaskStatus.SKIPPED), restored.getStatus("c")); + assertEquals(DagCompletionReason.ALL_COMPLETED, restored.completionReason()); + } + + @Test + void emptyDagResultHasZeroCounts() { + var r = new DagResultImpl(new LinkedHashMap<>(), DagCompletionReason.ALL_COMPLETED); + assertEquals(0, r.totalCount()); + assertEquals(0, r.successCount()); + assertEquals(0, r.failureCount()); + assertEquals(0, r.skippedCount()); + assertTrue(r.getResult("nope").isEmpty()); + assertTrue(r.getStatus("nope").isEmpty()); + r.throwIfError(); // must not throw + } + + @Test + void serdeRoundTripRehydratesMapResultTask() { + var serdes = new DagResultSerDes(new JacksonSerDes()); + var mapResult = new software.amazon.lambda.durable.model.MapResult<>( + java.util.List.of( + software.amazon.lambda.durable.model.MapResult.MapResultItem.succeeded("x"), + software.amazon.lambda.durable.model.MapResult.MapResultItem.succeeded("y")), + software.amazon.lambda.durable.model.ConcurrencyCompletionStatus.ALL_COMPLETED); + Map> m = new LinkedHashMap<>(); + m.put("m", ok("m", mapResult)); + var original = new DagResultImpl(m, DagCompletionReason.ALL_COMPLETED); + + var restored = serdes.deserialize( + serdes.serialize(original), TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + + var rehydrated = restored.getResult("m").orElseThrow(); + assertTrue( + rehydrated instanceof software.amazon.lambda.durable.model.MapResult, + "MAP result must rehydrate to a MapResult instance, was: " + rehydrated.getClass()); + var mr = (software.amazon.lambda.durable.model.MapResult) rehydrated; + assertEquals(2, mr.size()); + assertEquals("x", mr.getResult(0)); + assertEquals("y", mr.getResult(1)); + } + + @Test + void serdeRoundTripRehydratesNestedDagResultTask() { + var serdes = new DagResultSerDes(new JacksonSerDes()); + Map> inner = new LinkedHashMap<>(); + inner.put("leaf", ok("leaf", "deep")); + var innerDag = new DagResultImpl(inner, DagCompletionReason.ALL_COMPLETED); + + Map> outer = new LinkedHashMap<>(); + outer.put("nested", ok("nested", innerDag)); + var original = new DagResultImpl(outer, DagCompletionReason.ALL_COMPLETED); + + var restored = serdes.deserialize( + serdes.serialize(original), TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + + var rehydrated = restored.getResult("nested").orElseThrow(); + assertTrue( + rehydrated instanceof software.amazon.lambda.durable.dag.DagResult, + "DAG result must rehydrate to a DagResult instance, was: " + rehydrated.getClass()); + var nested = (software.amazon.lambda.durable.dag.DagResult) rehydrated; + assertEquals(1, nested.totalCount()); + assertEquals(Optional.of("deep"), nested.getResult("leaf")); + assertEquals(DagCompletionReason.ALL_COMPLETED, nested.completionReason()); + } + + /** Simple record used to prove PLAIN typed-result fidelity across a serde round-trip. */ + public record Point(int x, int y) {} + + @Test + void serdeRoundTripPreservesPlainPojoType() { + // The type graph maps the task name to its declared result type, so PLAIN reconstruction into the concrete + // type is permitted (recovered by name from the registered graph, not from a checkpoint-stored class name). + var serdes = new DagResultSerDes( + new JacksonSerDes(), new DagResultTypes(Map.of("p", TypeToken.get(Point.class)), Map.of())); + Map> m = new LinkedHashMap<>(); + m.put("p", ok("p", new Point(3, 4))); + var original = new DagResultImpl(m, DagCompletionReason.ALL_COMPLETED); + + var restored = serdes.deserialize( + serdes.serialize(original), TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + + var rehydrated = restored.getResult("p").orElseThrow(); + assertTrue( + rehydrated instanceof Point, + "PLAIN POJO result must rehydrate to its concrete type, not a generic map; was: " + + rehydrated.getClass()); + assertEquals(new Point(3, 4), rehydrated); + } + + @Test + void serdePlainResultUnknownTaskFallsBackToGenericTree() { + // A PLAIN result whose task is NOT in the type graph (here: empty) is never reconstructed into a concrete + // type — no class is resolved from the checkpoint and the value degrades to a generic JSON tree. This is the + // regression guard for the former checkpoint-sourced arbitrary-class-load hazard (there is now no + // Class.forName on any checkpoint string at all). + var serdes = new DagResultSerDes(new JacksonSerDes(), DagResultTypes.empty()); + Map> m = new LinkedHashMap<>(); + m.put("p", ok("p", new Point(3, 4))); + var original = new DagResultImpl(m, DagCompletionReason.ALL_COMPLETED); + + var restored = serdes.deserialize( + serdes.serialize(original), TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + + var rehydrated = restored.getResult("p").orElseThrow(); + assertFalse( + rehydrated instanceof Point, + "A task absent from the type graph must NOT be reconstructed; expected a generic tree, got: " + + rehydrated.getClass()); + } + + @Test + void serdeRoundTripReconstructsError() { + var serdes = new DagResultSerDes(new JacksonSerDes()); + Map> m = new LinkedHashMap<>(); + m.put("bad", fail("bad", DagTaskError.of(new IllegalStateException("nope")))); + var original = new DagResultImpl(m, DagCompletionReason.COMPLETED_WITH_FAILURES); + var restored = serdes.deserialize( + serdes.serialize(original), TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + assertEquals(1, restored.failureCount()); + var err = restored.failed().get(0).error().orElseThrow(); + assertEquals(IllegalStateException.class.getName(), err.errorType()); + assertEquals("nope", err.errorMessage()); + assertFalse(err.cause().isPresent()); // cause is not serialized + } +} diff --git a/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java new file mode 100644 index 000000000..89303de63 --- /dev/null +++ b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.dag.DagCyclicDependencyException; +import software.amazon.lambda.durable.dag.DagDuplicateTaskException; +import software.amazon.lambda.durable.dag.DagInvalidDependencyException; +import software.amazon.lambda.durable.dag.DagInvalidTaskNameException; + +class DagValidatorTest { + + private static TaskHandleImpl task(String name) { + return new TaskHandleImpl<>(name, TaskKind.STEP, null, null); + } + + @Test + void acyclicGraphPasses() { + var a = task("a"); + var b = task("b"); + var c = task("c").reads(a, b); + assertDoesNotThrow(() -> DagValidator.validate(List.of(a, b, (TaskHandleImpl) c))); + } + + @Test + void diamondIsNotACycle() { + var a = task("a"); + var b = task("b").after(a); + var c = task("c").after(a); + var d = task("d").after(b, c); + assertDoesNotThrow(() -> DagValidator.validate( + List.of(a, (TaskHandleImpl) b, (TaskHandleImpl) c, (TaskHandleImpl) d))); + } + + @Test + void selfLoopIsCycle() { + var a = task("a"); + a.after(a); + assertThrows(DagCyclicDependencyException.class, () -> DagValidator.validate(List.of(a))); + } + + @Test + void twoCycleDetected() { + var a = task("a"); + var b = task("b"); + a.after(b); + b.after(a); + assertThrows(DagCyclicDependencyException.class, () -> DagValidator.validate(List.of(a, b))); + } + + @Test + void deepCycleDetected() { + var a = task("a"); + var b = task("b").after(a); + var c = task("c").after(b); + a.after(c); + assertThrows( + DagCyclicDependencyException.class, + () -> DagValidator.validate(List.of(a, (TaskHandleImpl) b, (TaskHandleImpl) c))); + } + + @Test + void badNameRejected() { + assertThrows(DagInvalidTaskNameException.class, () -> DagValidator.validate(List.of(task("has-dash")))); + assertThrows(DagInvalidTaskNameException.class, () -> DagValidator.validate(List.of(task("")))); + assertThrows(DagInvalidTaskNameException.class, () -> DagValidator.validate(List.of(task("x".repeat(101))))); + assertThrows(DagInvalidTaskNameException.class, () -> DagValidator.validate(List.of(task("my_DAG_NODE_T_x")))); + } + + @Test + void duplicateNamesRejected() { + assertThrows(DagDuplicateTaskException.class, () -> DagValidator.validate(List.of(task("dup"), task("dup")))); + } + + @Test + void foreignDependencyRejected() { + var registered = task("a"); + var foreign = task("foreign"); + var b = task("b").after(foreign); + assertThrows( + DagInvalidDependencyException.class, + () -> DagValidator.validate(List.of(registered, (TaskHandleImpl) b))); + } +} diff --git a/dag/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java new file mode 100644 index 000000000..a14876f3a --- /dev/null +++ b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.extension.ExtensionOperation; + +class ReservedOperationContextTest { + @Test + void suppliesReservedOperationThenDelegates() { + var delegate = mock(ExtensionContext.class); + var reserved = mock(ExtensionOperation.class); + var next = mock(ExtensionOperation.class); + when(delegate.reserve("next")).thenReturn(next); + var context = new ReservedOperationContext(delegate, "node", reserved); + + assertSame(reserved, context.reserve("node")); + assertSame(next, context.reserve("next")); + + verify(delegate).reserve("next"); + } + + @Test + void rejectsUnexpectedFirstReservation() { + var context = + new ReservedOperationContext(mock(ExtensionContext.class), "node", mock(ExtensionOperation.class)); + + assertThrows(IllegalStateException.class, () -> context.reserve("other")); + } + + @Test + void delegatesStableLocalReservations() { + var delegate = mock(ExtensionContext.class); + var local = mock(ExtensionOperation.class); + when(delegate.reserve("child", "stable-id")).thenReturn(local); + var context = new ReservedOperationContext(delegate, "node", mock(ExtensionOperation.class)); + + assertSame(local, context.reserve("child", "stable-id")); + + verify(delegate).reserve("child", "stable-id"); + } +} diff --git a/dag/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java new file mode 100644 index 000000000..0a43a7256 --- /dev/null +++ b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.dag.Deps; +import software.amazon.lambda.durable.dag.TaskExecution; +import software.amazon.lambda.durable.dag.TaskStatus; +import software.amazon.lambda.durable.dag.TriggerRule; + +class TaskHandleTest { + + private static TaskHandleImpl handle(String name) { + return new TaskHandleImpl<>(name, TaskKind.STEP, null, null); + } + + @Test + void buildersMutateHandleState() { + var a = handle("a"); + var b = handle("b"); + var c = handle("c"); + c.reads(a).after(b).triggerRule(TriggerRule.ALL_DONE).runIf(deps -> true); + + assertEquals(1, c.inlineDeps().size()); + assertTrue(c.inlineDeps().contains(a)); + assertEquals(2, c.allDeps().size()); // inline (a) + ordering-only (b) + assertTrue(c.allDeps().contains(a)); + assertTrue(c.allDeps().contains(b)); + assertEquals(Optional.of(TriggerRule.ALL_DONE), c.triggerRuleOpt()); + assertTrue(c.runIfOpt().isPresent()); + } + + @Test + void depsGetReturnsTypedResultForDeclaredInlineDep() { + var a = handle("a"); + var b = handle("b"); + b.reads(a); + + Map> results = new LinkedHashMap<>(); + results.put( + "a", + new TaskExecution<>( + "a", + TaskStatus.SUCCEEDED, + Optional.empty(), + Optional.of("hello"), + Optional.empty(), + Optional.empty(), + Optional.empty())); + + Deps deps = new DepsImpl("b", b.inlineDeps(), results); + assertEquals(Optional.of("hello"), deps.get(a)); + } + + @Test + void depsGetOnUndeclaredHandleThrows() { + var a = handle("a"); + var b = handle("b"); // b does NOT read a + Deps deps = new DepsImpl("b", b.inlineDeps(), new LinkedHashMap<>()); + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> deps.get(a)); + // The message must name both the offending task and the handle, and + // point at .reads(...) on the OFFENDING task's own registration -- + // not on the dependency's -- since that is the one-line fix. + assertTrue(ex.getMessage().contains("'b'")); + assertTrue(ex.getMessage().contains("'a'")); + assertTrue(ex.getMessage().contains(".reads(")); + } + + @Test + void depsGetReturnsEmptyForNonSucceededUpstream() { + var a = handle("a"); + var b = handle("b"); + b.reads(a); + Map> results = new LinkedHashMap<>(); + results.put( + "a", + new TaskExecution<>( + "a", + TaskStatus.FAILED, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty())); + Deps deps = new DepsImpl("b", b.inlineDeps(), results); + assertTrue(deps.get(a).isEmpty()); + } + + /** + * Models the compensation-task scenario the {@code Optional} return type exists for: a downstream task with a + * non-ALL_SUCCESS trigger rule (here {@code ALL_DONE}) runs even though its inline dependency FAILED. Reading that + * dependency's result via {@code deps.get(handle)} must yield {@link Optional#empty()}, not a null masquerading as + * a present value. + */ + @Test + void depsGetReturnsEmptyForFailedUpstreamUnderAllDoneTriggerRule() { + var upstream = handle("upstream"); // designed to fail + var compensate = handle("compensate"); + compensate.reads(upstream).triggerRule(TriggerRule.ALL_DONE); + + // Snapshot the scheduler would hand to the compensation task: upstream terminal but FAILED. + Map> results = new LinkedHashMap<>(); + results.put( + "upstream", + new TaskExecution<>( + "upstream", + TaskStatus.FAILED, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty())); + + Deps deps = new DepsImpl("compensate", compensate.inlineDeps(), results); + + Optional result = deps.get(upstream); + assertTrue(result.isEmpty(), "failed upstream under ALL_DONE must surface as Optional.empty()"); + } +} diff --git a/dag/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java new file mode 100644 index 000000000..4c1c996bf --- /dev/null +++ b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static software.amazon.lambda.durable.dag.TaskStatus.FAILED; +import static software.amazon.lambda.durable.dag.TaskStatus.SKIPPED; +import static software.amazon.lambda.durable.dag.TaskStatus.SUCCEEDED; + +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.dag.TriggerRule; + +/** Tests the internal trigger-rule evaluator (logic moved off the public {@link TriggerRule} value enum). */ +class TriggerRuleEvaluatorTest { + + @Test + void emptyUpstreamVacuousCases() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ALL_SUCCESS, List.of())); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ALL_FAILED, List.of())); + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ALL_DONE, List.of())); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ANY_SUCCESS, List.of())); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ANY_FAILED, List.of())); + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.NONE_FAILED, List.of())); + } + + @Test + void allSuccess() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ALL_SUCCESS, List.of(SUCCEEDED, SUCCEEDED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ALL_SUCCESS, List.of(SUCCEEDED, FAILED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ALL_SUCCESS, List.of(SUCCEEDED, SKIPPED))); + } + + @Test + void allFailed() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ALL_FAILED, List.of(FAILED, FAILED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ALL_FAILED, List.of(FAILED, SUCCEEDED))); + } + + @Test + void allDone() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ALL_DONE, List.of(SUCCEEDED, FAILED, SKIPPED))); + } + + @Test + void anySuccess() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ANY_SUCCESS, List.of(FAILED, SUCCEEDED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ANY_SUCCESS, List.of(FAILED, SKIPPED))); + } + + @Test + void anyFailed() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ANY_FAILED, List.of(SUCCEEDED, FAILED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ANY_FAILED, List.of(SUCCEEDED, SKIPPED))); + } + + @Test + void noneFailed() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.NONE_FAILED, List.of(SUCCEEDED, SKIPPED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.NONE_FAILED, List.of(SUCCEEDED, FAILED))); + } +} diff --git a/docs/DAG_STATUS_JAVA.md b/docs/DAG_STATUS_JAVA.md new file mode 100644 index 000000000..c412a7a23 --- /dev/null +++ b/docs/DAG_STATUS_JAVA.md @@ -0,0 +1,31 @@ +# DAG Implementation Status - Java + +**Stability:** EXPERIMENTAL (`@Experimental` on public DAG symbols). + +**Artifact:** `software.amazon.lambda.durable:aws-durable-execution-sdk-java-dag`. + +## Extension SPI migration + +- Public entry points are static `DurableDagOperation.dag(...)` and `dagAsync(...)` methods. +- DAG API and implementation classes are isolated from the core SDK in the optional `dag` Maven module. +- `DurableContext` has no DAG-specific methods. +- The DAG container and tasks use `ExtensionContext` and opaque `ExtensionOperation` reservations. +- Task IDs use `reserve(name, "DAG_NODE_T_" + name)`; no DAG-specific operation-ID API is required. +- DAG uses string extension subtypes instead of adding values to `OperationSubType`. +- DAG adapts its pre-reserved nodes to the unchanged map, parallel, and wait-for-condition operation facades. +- `DagException` extends `DurableExecutionException` directly. +- Large results use `ExtensionContextResult.replayChildrenAboveSize`. +- DAG scheduler code does not depend on `context`, `execution`, or `primitive` implementation packages. + +## Preserved behavior + +- Eager registration and validation before any DAG operation launches. +- Stable task identity across replay. +- Typed task results, nested DAGs, callbacks, map, parallel, waits, invoke, and child contexts. +- Trigger rules, `runIf`, compensation, concurrency limits, and completion policies. +- Compact large-result replay while preserving aggregate counts. + +## Current limitations + +- `TaskExecution.startedAt` and `completedAt` are empty because the extension SPI does not expose operation timestamps. +- Parallel branches use `Consumer` and do not receive DAG dependencies. diff --git a/docs/core/dag.md b/docs/core/dag.md new file mode 100644 index 000000000..9f3e0cb35 --- /dev/null +++ b/docs/core/dag.md @@ -0,0 +1,212 @@ +# DAG (`DurableDagOperation.dag()`) — ⚠️ EXPERIMENTAL + +> **⚠️ EXPERIMENTAL.** DAG support is an experimental feature and may be changed or removed in future releases +> **without a major-version bump**. Every public DAG type/method is annotated with +> `@software.amazon.lambda.durable.dag.Experimental` and carries a Javadoc `@apiNote`. Do not depend on it in +> production until it is promoted to stable. + +## Installation + +DAG support is distributed as a separate opt-in artifact. Add it alongside the core SDK using the same version: + +```xml + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-dag + ${aws-lambda-durable-sdk.version} + +``` + +`DurableDagOperation.dag(...)` declares and runs a **directed acyclic graph of tasks** with typed dependencies. You describe +the graph once in a declarative registration phase; the runtime schedules tasks topologically, runs independent chains +concurrently via `DurableFuture`, evaluates per-task trigger rules and `runIf` predicates, and aggregates results into +a `DagResult`. + +DAG is implemented as an extension operation using the public extension SPI. The DAG container obtains the current +`ExtensionContext` and reserves one context operation. Inside that context, every task is reserved with the stable +local ID `DAG_NODE_T_{name}` through `ExtensionContext.reserve(name, localOperationId)`. The SDK namespaces and hashes +that local ID, so graph traversal order can change without changing task operation IDs. DAG does not add methods to +`DurableContext`, operation subtypes to the core enum, or implementation-only operation-ID APIs. + +## Entry points + +```java +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; + +DagResult dag(String name, Consumer register); +DagResult dag(String name, Consumer register, DagConfig config); +DurableFuture dagAsync(String name, Consumer register); +DurableFuture dagAsync(String name, Consumer register, DagConfig config); +``` + +These are static methods on `DurableDagOperation` and must be called from a durable context thread. `register` only +*declares* tasks; nothing executes until it returns and the graph is validated. + +## Declaring tasks and dependencies + +Each `DagContext` method registers one task and returns a typed `TaskHandle`. Every task function takes a `Deps` +as its first parameter (empty for roots). + +```java +DagResult r = dag("etl", d -> { + var a = d.step("a", String.class, (deps, s) -> fetchA()); // root: empty Deps + var b = d.step("b", String.class, (deps, s) -> fetchB()); + var c = d.step("c", String.class, (deps, s) -> // inline deps -> typed access + process(deps.get(a), deps.get(b))) + .reads(a, b); // .reads(...) = inline (typed) deps + d.step("notify", Void.class, (deps, s) -> notifyDone()) + .after(c); // .after(...) = ordering-only +}); +``` + +- `.reads(TaskHandle...)` — **inline** deps: gate scheduling **and** are retrievable via `Deps.get(handle)`. + Passing an undeclared handle to `Deps.get` throws `IllegalStateException`. Java cannot introspect a lambda body, so + inline deps must be declared explicitly. +- `.after(TaskHandle...)` — **ordering-only** deps: gate scheduling but are **not** retrievable via `Deps`. +- `deps.get(handle)` returns the upstream's declared type `T`; `deps.getOptional(handle)` returns `Optional` for + non-`ALL_SUCCESS` paths where an upstream may be FAILED/SKIPPED. + +Supported task kinds: `step`, `invoke`, `callback` (submitter-based), `wait`, `waitForCondition`, `runInChildContext`, +`map`, `parallel`, and nested `dag`. Per-task configuration reuses the existing `StepConfig`/`InvokeConfig`/ +`MapConfig`/`ParallelConfig`/`WaitForConditionConfig`/`WaitForCallbackConfig` types verbatim. + +## Trigger rules + +`.triggerRule(TriggerRule.X)` controls whether a task runs based on upstream terminal statuses (default +`ALL_SUCCESS`, or `DagConfig.defaultTriggerRule`): + +| Rule | Runs when … | Empty upstream | +| ------------- | --------------------------------- | -------------- | +| `ALL_SUCCESS` | every upstream SUCCEEDED | run | +| `ALL_FAILED` | every upstream FAILED | skip | +| `ALL_DONE` | all upstream terminal (any state) | run | +| `ANY_SUCCESS` | ≥1 upstream SUCCEEDED | skip | +| `ANY_FAILED` | ≥1 upstream FAILED | skip | +| `NONE_FAILED` | no upstream FAILED | run | + +A failed task is a **terminal state, not an abort**: by default the scheduler drains the reachable graph so +compensation tasks run. When the rule is not satisfied the task is `SKIPPED` (`SkipReason.TRIGGER_RULE`) and the skip +cascades downstream. Skips checkpoint nothing. + +## `runIf` + +`.runIf(Predicate)` is evaluated after the trigger rule passes; returning `false` skips the task +(`SkipReason.RUN_IF_PREDICATE`). Predicates must be **synchronous, deterministic, and pure** — they are re-evaluated on +every replay and are never checkpointed. + +### A throwing `runIf` aborts the DAG (it is **not** a task failure) + +Because a `runIf` predicate is pure scheduler-decision code, a predicate that **throws** is a *defect in deterministic +code*, not a business outcome — so it must not be reinterpreted as a task failure (which would fire every downstream +`ALL_FAILED` / `ANY_FAILED` / `ALL_DONE` compensation, e.g. a `NullPointerException` in a predicate issuing a refund). +Instead, a throwing `runIf` **aborts** the DAG: + +- The offending task gets **no terminal state** — it is neither `FAILED` nor `SKIPPED`. +- The scheduler **starts no further tasks**; tasks that already completed keep their checkpoints. +- The DAG container checkpoints a **failure** (durable and visible in history), and the `dag(...)` operation **fails** + with a typed **`DagPredicateException`** whose message names the offending task and whose **cause is the original + error** (message and stack trace preserved). `DagPredicateException.taskName()` returns the offending task's name. + +```java +try { + dag("cond", d -> { + var gate = d.step("gate", Integer.class, (deps, s) -> fetch()); + d.step("maybe", String.class, (deps, s) -> "ran") + .reads(gate) + .runIf(deps -> deps.get(gate) > threshold()); // if this throws, the whole DAG aborts + }); +} catch (DagPredicateException e) { + log.error("predicate for task {} threw", e.taskName(), e.getCause()); +} +``` + +> **Boundary note (Java-specific).** A DAG runs inside an extension context operation, so `DagPredicateException` is +> checkpointed and **reconstructed from its serialized form** before the `dag(...)` caller observes it. The +> reconstructed exception is a `DagPredicateException` that preserves its type, message, `taskName()`, and a cause +> carrying the original error's message and stack trace. As with every exception the SDK round-trips through a +> checkpoint, the **cause's concrete Java class is not preserved** (it degrades to `Throwable`); only a top-level +> exception's concrete type is recoverable. This is a general property of the SDK's exception serialization, not +> specific to `runIf`. +> +> A throwing task **body**, by contrast, is a normal task `FAILED` (see [Trigger rules](#trigger-rules)); only the +> *predicate* aborts. + +## Completion (threshold only in v1) + +`DagConfig.builder().completionConfig(...)` accepts one of six threshold policies: +`allCompleted`, `allSuccessful`, `firstSuccessful`, `minSuccessful(n)`, `toleratedFailureCount(n)`, +`toleratedFailurePercentage(p)`. Default (no `completionConfig`) drains the whole reachable graph. `completionReason()` +reports `ALL_COMPLETED`, `COMPLETED_WITH_FAILURES`, `MIN_SUCCESSFUL_REACHED`, or `FAILURE_TOLERANCE_EXCEEDED`. + +> **v2-deferred:** Custom-predicate (result-based) completion is **not** in v1. `DagCompletionConfig` exposes only the +> threshold factories, and `DagCompletionReason.CUSTOM_COMPLETION_*` are reserved-but-unreachable. + +## Results + +`DagResult` provides `getResult(TaskHandle) -> Optional` (typed) and `getResult(String) -> Optional` +(untyped), `getStatus(...)`, grouped views (`succeeded()`/`failed()`/`skipped()`), counts, `completionReason()`, and +`throwIfError()` (throws `DagExecutionException` iff `failureCount() > 0`). + +## Configuration + +```java +DagConfig.builder() + .maxConcurrency(4) // >= 1; default 40. Limits top-level tasks only. + .defaultTriggerRule(TriggerRule.ALL_DONE) + .completionConfig(DagCompletionConfig.minSuccessful(3)) + .build(); +``` + +There is **no `summaryGenerator`** (see below). + +### Default concurrency + +When `maxConcurrency` is unset, the DAG scheduler runs at most **40** top-level tasks concurrently (it was previously +unbounded). An explicit `maxConcurrency` always wins, including a value above 40; the `>= 1` validation is unchanged. + +The bound applies to the **DAG scheduler only** — the top-level tasks of *this* DAG, one level. It is **not** inherited +by a task's own internal fan-out: + +- A `map` or `parallel` task still defaults to **unlimited** internal fan-out. A DAG task that is a 500-item map still + fans out to 500 items internally. This divergence from `map`/`parallel` is deliberate. +- A **nested `dag`** task gets its own independent default of 40, scoped to its own top-level tasks. + +Note the interaction with early completion: for a graph wider than 40 that uses `completionConfig`, capping concurrency +changes which tasks ever start, so more tasks end up **absent** (never started) rather than reaching a terminal state. +Absent tasks count only toward `totalCount`; the early-completion semantics are unchanged, but the population of +started tasks shifts. + +## Replay & large results (no summary envelope) + +Because task IDs use stable local reservations (`DAG_NODE_T_{name}`), the scheduler can traverse in any order across +replays: each task's checkpoint fast path returns its result under the same ID, so re-running the scheduler +reconstructs an identical `DagResult` with correct types. Small aggregates (< 256 KB) are checkpointed directly using a +`resultKind`-tagged serialization that preserves nested `MapResult`/`DagResult` instances. **Large aggregates +(≥ 256 KB) use `ExtensionContextResult.replayChildrenAboveSize`**: the DAG context body re-runs, every task hits its +checkpoint fast path (no task-body re-execution), and the `DagResult` is rebuilt in memory. The compact replay state +retains aggregate counts while task detail remains in child operations. There is deliberately **no JS-style +`DagSummary` / `summaryGenerator` envelope**. + +## Validation & exceptions + +Validation runs once after `register` returns, before any task launches, and throws at the `dag(...)` call site: + +- `DagInvalidTaskNameException` — name must match `^[a-zA-Z0-9_]+$`, be ≤ 100 chars, and not contain `DAG_NODE_T_`. +- `DagDuplicateTaskException` — duplicate task name in the same scope. +- `DagInvalidDependencyException` — dependency handle not registered in this scope. +- `DagCyclicDependencyException` — the dependency graph contains a cycle (detected via Kahn's algorithm; a diamond is + not a cycle). + +All extend `DagException` → `DurableExecutionException` (`RuntimeException`). DAG exceptions are extension-level +errors, not failures associated with one primitive operation. + +A **runtime** DAG exception — `DagPredicateException` — is thrown when a task's `runIf` predicate throws; it aborts the +DAG (see [`runIf`](#runif) above) rather than surfacing at the `dag(...)` call site during registration. It also +extends `DagException`. + +## Notes / v1 limitations + +- `TaskExecution.startedAt`/`completedAt` are not populated because the extension SPI does not expose operation + timestamps. +- `parallel` branches are declared against the existing `ParallelDurableFuture` (`Consumer`); + branches do not receive `Deps`. diff --git a/examples/pom.xml b/examples/pom.xml index 4772be59a..af61d34b7 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -31,6 +31,13 @@ ${project.version} + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-dag + ${project.version} + + software.amazon.lambda.durable diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagCompensationExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagCompensationExample.java new file mode 100644 index 000000000..73b36572a --- /dev/null +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagCompensationExample.java @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.operation.dag; + +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; + +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.dag.DagResult; +import software.amazon.lambda.durable.dag.TriggerRule; +import software.amazon.lambda.durable.retry.RetryStrategies; + +/** + * DAG example: saga-style compensation via trigger rules. {@code charge} fails; {@code refund} fires on + * {@link TriggerRule#ALL_FAILED}; {@code fulfill} is skipped (default ALL_SUCCESS over a failed upstream); + * {@code audit} always runs ({@link TriggerRule#ALL_DONE}). The DAG completes with {@code COMPLETED_WITH_FAILURES}. + * Returns a pipe-delimited summary of the completion reason and per-task statuses. + */ +public class DagCompensationExample extends DurableHandler { + + @Override + public String handleRequest(String input, DurableContext context) { + var noRetry = StepConfig.builder() + .retryStrategy(RetryStrategies.Presets.NO_RETRY) + .build(); + DagResult r = dag("saga", d -> { + var charge = d.step( + "charge", + String.class, + (deps, s) -> { + throw new RuntimeException("charge failed"); + }, + noRetry); + d.step("refund", String.class, (deps, s) -> "refunded") + .after(charge) + .triggerRule(TriggerRule.ALL_FAILED); + d.step("fulfill", String.class, (deps, s) -> "fulfilled").after(charge); + d.step("audit", String.class, (deps, s) -> "audited").after(charge).triggerRule(TriggerRule.ALL_DONE); + }); + return r.completionReason().name() + + "|" + r.getStatus("charge").map(Enum::name).orElse("?") + + "|" + r.getStatus("refund").map(Enum::name).orElse("?") + + "|" + r.getStatus("fulfill").map(Enum::name).orElse("?") + + "|" + r.getStatus("audit").map(Enum::name).orElse("?"); + } +} diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagDiamondExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagDiamondExample.java new file mode 100644 index 000000000..ec91463a6 --- /dev/null +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagDiamondExample.java @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.operation.dag; + +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; + +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.dag.DagResult; + +/** + * DAG example: a diamond (a -> {b, c} -> dd) exercising typed inline dependencies via {@code .reads(...)} and + * {@code deps.get(...)}. Returns the terminal join result so the cloud test can assert on a simple string. + */ +public class DagDiamondExample extends DurableHandler { + + @Override + public String handleRequest(String input, DurableContext context) { + DagResult r = dag("etl", d -> { + var a = d.step("a", String.class, (deps, s) -> "A"); + var b = d.step("b", String.class, (deps, s) -> deps.get(a).orElseThrow() + "B") + .reads(a); + var c = d.step("c", String.class, (deps, s) -> deps.get(a).orElseThrow() + "C") + .reads(a); + d.step( + "dd", + String.class, + (deps, s) -> deps.get(b).orElseThrow() + deps.get(c).orElseThrow()) + .reads(b, c); + }); + return (String) r.getResult("dd").orElse("MISSING"); + } +} diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagRunIfExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagRunIfExample.java new file mode 100644 index 000000000..c64c7bbb8 --- /dev/null +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagRunIfExample.java @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.operation.dag; + +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; + +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.dag.DagResult; + +/** + * DAG example: {@code runIf} conditional branching with skip cascade. The {@code gate} step yields 0, so + * {@code maybe}'s run-if predicate is false and it is SKIPPED; {@code after} (default ALL_SUCCESS over a skipped + * upstream) also SKIPS. Returns a pipe-delimited summary of the two task statuses. + */ +public class DagRunIfExample extends DurableHandler { + + @Override + public String handleRequest(String input, DurableContext context) { + DagResult r = dag("cond", d -> { + var gate = d.step("gate", Integer.class, (deps, s) -> 0); + var maybe = d.step("maybe", String.class, (deps, s) -> "ran") + .reads(gate) + .runIf(deps -> ((Integer) deps.get(gate).orElseThrow()) > 0); + d.step("after", String.class, (deps, s) -> "after").after(maybe); + }); + return r.getStatus("maybe").map(Enum::name).orElse("?") + + "|" + + r.getStatus("after").map(Enum::name).orElse("?"); + } +} diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagWaitResumeExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagWaitResumeExample.java new file mode 100644 index 000000000..196713804 --- /dev/null +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagWaitResumeExample.java @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.operation.dag; + +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; + +import java.time.Duration; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.dag.DagResult; + +/** + * DAG example: a diamond with an in-DAG {@code wait} node between the concurrent fan-out (b, c) and the join. The wait + * forces a real suspend/replay on the cloud backend; name-based task IDs must make the join deterministic and the + * per-task checkpoints must fast-path on resume (no body re-execution). Returns the join result ("ABAC"). + */ +public class DagWaitResumeExample extends DurableHandler { + + @Override + public String handleRequest(String input, DurableContext context) { + DagResult r = dag("diamond_wait", d -> { + var a = d.step("a", String.class, (deps, s) -> "A"); + var b = d.step("b", String.class, (deps, s) -> deps.get(a).orElseThrow() + "B") + .reads(a); + var c = d.step("c", String.class, (deps, s) -> deps.get(a).orElseThrow() + "C") + .reads(a); + var w = d.wait("w", Duration.ofSeconds(5)).after(b, c); + d.step( + "join", + String.class, + (deps, s) -> deps.get(b).orElseThrow() + deps.get(c).orElseThrow()) + .reads(b, c) + .after(w); + }); + return (String) r.getResult("join").orElse("MISSING"); + } +} diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/operation/dag/DagExamplesTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/operation/dag/DagExamplesTest.java new file mode 100644 index 000000000..44e6e2f67 --- /dev/null +++ b/examples/src/test/java/software/amazon/lambda/durable/examples/operation/dag/DagExamplesTest.java @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.operation.dag; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +class DagExamplesTest { + + @Test + void diamondExampleCompletes() { + var runner = LocalDurableTestRunner.create(String.class, new DagDiamondExample()); + + var result = runner.runUntilComplete("go"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("ABAC", result.getResult(String.class)); + } + + @Test + void compensationExampleReportsTaskStatuses() { + var runner = LocalDurableTestRunner.create(String.class, new DagCompensationExample()); + + var result = runner.runUntilComplete("go"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("COMPLETED_WITH_FAILURES|FAILED|SUCCEEDED|SKIPPED|SUCCEEDED", result.getResult(String.class)); + } + + @Test + void runIfExampleReportsSkipCascade() { + var runner = LocalDurableTestRunner.create(String.class, new DagRunIfExample()); + + var result = runner.runUntilComplete("go"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("SKIPPED|SKIPPED", result.getResult(String.class)); + } + + @Test + void waitResumeExampleCompletesAfterReplay() { + var runner = LocalDurableTestRunner.create(String.class, new DagWaitResumeExample()); + + var result = runner.runUntilComplete("go"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("ABAC", result.getResult(String.class)); + } +} diff --git a/pom.xml b/pom.xml index dc30be7ef..8ec6ad0c3 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ sdk + dag sdk-testing sdk-integration-tests otel-plugin diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java index aaf2b701c..f9a11a136 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java @@ -25,7 +25,6 @@ import software.amazon.lambda.durable.extension.ExtensionContextConfig; import software.amazon.lambda.durable.extension.ExtensionContextReplayContext; import software.amazon.lambda.durable.extension.ExtensionContextResult; -import software.amazon.lambda.durable.extension.ExtensionOperation; import software.amazon.lambda.durable.model.MapResult; import software.amazon.lambda.durable.model.SafeCloseable; import software.amazon.lambda.durable.serde.SerDes;

Registration only declares tasks (it launches nothing) and validation is pure graph analysis, so both + * are deterministic and run eagerly at the {@code dag(...)} call site — not inside the child-context body. + * This preserves the API contract that graph validation errors are raised directly from the {@code dag(...)} call + * before any extension operation is reserved or launched. + */ + public static DagContextImpl registerAndValidate(Consumer register) { + var dctx = new DagContextImpl(); + register.accept(dctx); + DagValidator.validate(dctx.tasks()); + return dctx; + } + + /** Resolves the SerDes used to (de)serialize a DAG's aggregate result. */ + public static SerDes dagSerDes(DagConfig config, SerDes defaultSerDes, DagContextImpl dctx) { + return config.serDes().orElseGet(() -> new DagResultSerDes(defaultSerDes, dctx.collectResultTypes())); + } + + /** Starts a registered DAG using a caller-provided extension-operation reservation. */ + public static DurableFuture start( + ExtensionContext context, ExtensionOperation operation, DagContextImpl dag, DagConfig config) { + var serDes = dagSerDes(config, context.getDurableConfig().getSerDes(), dag); + return operation.runInChildContextAsync( + DAG_SUBTYPE, + TypeToken.get(DagResult.class), + () -> executeDag(dag, config, serDes), + ExtensionContextConfig.builder() + .serDes(serDes) + .suppressLateChildCheckpoints(true) + .build()); + } + + private static ExtensionContextResult executeDag(DagContextImpl dag, DagConfig config, SerDes serDes) { + var result = DagResultImpl.from(DagExecutor.run(dag.tasks(), ExtensionContext.getCurrentContext(), config)); + var replayState = serDes instanceof DagResultSerDes dagSerDes ? dagSerDes.replayState(result) : null; + return ExtensionContextResult.replayChildrenAboveSize(result, replayState, LARGE_RESULT_THRESHOLD); + } + + // ── step ───────────────────────────────────────────────────────────────── + @Override + public TaskHandle step(String name, Class type, DagStepFunction fn) { + return step(name, TypeToken.get(type), fn, StepConfig.builder().build()); + } + + @Override + public TaskHandle step(String name, TypeToken type, DagStepFunction fn) { + return step(name, type, fn, StepConfig.builder().build()); + } + + @Override + public TaskHandle step(String name, Class type, DagStepFunction fn, StepConfig config) { + return step(name, TypeToken.get(type), fn, config); + } + + @Override + public TaskHandle step(String name, TypeToken type, DagStepFunction fn, StepConfig config) { + recordResultType(name, type); + TaskExecutor exec = (ctx, operation, deps) -> operation.stepAsync( + STEP_SUBTYPE, + type, + ignored -> ExtensionStepResult.succeed(fn.apply(deps, StepContext.getCurrentContext())), + ExtensionStepConfig.builder() + .serDes(config.serDes()) + .retryStrategy(adapt(config.retryStrategy())) + .semanticsPerRetry(adapt(config.semanticsPerRetry())) + .build()); + return register(new TaskHandleImpl<>(name, TaskKind.STEP, exec, config)); + } + + private static ExtensionStepConfig.RetryStrategy adapt(RetryStrategy retryStrategy) { + return (error, state, attempt) -> { + var decision = retryStrategy.makeRetryDecision(error, attempt); + return decision.shouldRetry() + ? ExtensionStepResult.retry(state, decision.delay()) + : ExtensionStepResult.doNotRetry(); + }; + } + + private static ExtensionStepConfig.StepSemantics adapt(StepSemantics semantics) { + return switch (semantics) { + case AT_LEAST_ONCE_PER_RETRY -> ExtensionStepConfig.StepSemantics.AT_LEAST_ONCE_PER_RETRY; + case AT_MOST_ONCE_PER_RETRY -> ExtensionStepConfig.StepSemantics.AT_MOST_ONCE_PER_RETRY; + }; + } + + // ── step: positional-arity typed-deps sugar (§2.7) ───────────────────────── + @Override + public TaskHandle step(String name, Class type, TaskHandle a, DagStep1Function fn) { + return step(name, type, (deps, sc) -> fn.apply(deps.get(a).orElse(null), sc)) + .reads(a); + } + + @Override + public TaskHandle step( + String name, Class type, TaskHandle a, TaskHandle b, DagStep2Function fn) { + return step( + name, + type, + (deps, sc) -> + fn.apply(deps.get(a).orElse(null), deps.get(b).orElse(null), sc)) + .reads(a, b); + } + + @Override + public TaskHandle step( + String name, + Class type, + TaskHandle a, + TaskHandle b, + TaskHandle c, + DagStep3Function fn) { + return step( + name, + type, + (deps, sc) -> fn.apply( + deps.get(a).orElse(null), + deps.get(b).orElse(null), + deps.get(c).orElse(null), + sc)) + .reads(a, b, c); + } + + // ── invoke ─────────────────────────────────────────────────────────────── + @Override + public TaskHandle invoke(String name, String functionName, Class type, DagPayloadFunction payloadFn) { + return invoke( + name, functionName, type, payloadFn, InvokeConfig.builder().build()); + } + + @Override + public TaskHandle invoke( + String name, String functionName, Class type, DagPayloadFunction payloadFn, InvokeConfig config) { + var typeToken = TypeToken.get(type); + recordResultType(name, typeToken); + TaskExecutor exec = (ctx, operation, deps) -> operation.invokeAsync( + INVOKE_SUBTYPE, + functionName, + payloadFn.apply(deps), + typeToken, + ExtensionInvokeConfig.builder() + .payloadSerDes(config.payloadSerDes()) + .serDes(config.serDes()) + .tenantId(config.tenantId()) + .build()); + return register(new TaskHandleImpl<>(name, TaskKind.INVOKE, exec, config)); + } + + // ── callback (submitter-based waitForCallback) ───────────────────────────── + @Override + public TaskHandle callback(String name, Class type, DagCallbackSubmitter submitter) { + return callback(name, type, submitter, WaitForCallbackConfig.builder().build()); + } + + @Override + public TaskHandle callback( + String name, Class type, DagCallbackSubmitter submitter, WaitForCallbackConfig config) { + var typeToken = TypeToken.get(type); + recordResultType(name, typeToken); + TaskExecutor exec = (ctx, operation, deps) -> callback(operation, name, typeToken, submitter, deps, config); + return register(new TaskHandleImpl<>(name, TaskKind.CALLBACK, exec, config)); + } + + private static DurableFuture callback( + ExtensionOperation operation, + String name, + TypeToken type, + DagCallbackSubmitter submitter, + Deps deps, + WaitForCallbackConfig config) { + return operation.runInChildContextAsync( + CALLBACK_SUBTYPE, + type, + () -> ExtensionContextResult.replayChildrenAboveSize( + DurableWaitForCallbackOperation.waitForCallbackAsync( + ExtensionContext.getCurrentContext(), + name, + type, + (callbackId, step) -> submitter.apply(deps, callbackId, step), + config.toOperationConfig()) + .get(), + null, + LARGE_RESULT_THRESHOLD), + ExtensionContextConfig.builder() + .serDes(config.stepConfig().serDes()) + .build()); + } + + // ── wait ───────────────────────────────────────────────────────────────── + @Override + public TaskHandle wait(String name, Duration duration) { + TaskExecutor exec = (ctx, operation, deps) -> operation.waitAsync(WAIT_SUBTYPE, duration); + return register(new TaskHandleImpl<>(name, TaskKind.WAIT, exec, duration)); + } + + // ── waitForCondition ────────────────────────────────────────────────────── + @Override + public TaskHandle waitForCondition( + String name, Class type, DagConditionFunction check, WaitForConditionConfig config) { + var typeToken = TypeToken.get(type); + recordResultType(name, typeToken); + TaskExecutor exec = (ctx, operation, deps) -> DurableWaitForConditionOperation.waitForConditionAsync( + new ReservedOperationContext(ctx, name, operation), + name, + typeToken, + (state, step) -> { + var result = check.apply(deps, state, step); + return new DurableWaitForConditionOperation.WaitForConditionResult<>( + result.value(), result.isDone()); + }, + config.toOperationConfig()); + return register(new TaskHandleImpl<>(name, TaskKind.WAIT_FOR_CONDITION, exec, config)); + } + + // ── runInChildContext ────────────────────────────────────────────────────── + @Override + public TaskHandle runInChildContext(String name, Class type, DagChildFunction fn) { + return runInChildContext(name, TypeToken.get(type), fn); + } + + @Override + public TaskHandle runInChildContext(String name, TypeToken type, DagChildFunction fn) { + recordResultType(name, type); + TaskExecutor exec = (ctx, operation, deps) -> operation.runInChildContextAsync( + RUN_IN_CHILD_CONTEXT_SUBTYPE, + type, + () -> ExtensionContextResult.replayChildrenAboveSize( + fn.apply(deps, DurableContext.getCurrentContext()), null, LARGE_RESULT_THRESHOLD), + ExtensionContextConfig.builder().build()); + return register(new TaskHandleImpl<>(name, TaskKind.CHILD, exec, null)); + } + + // ── map ────────────────────────────────────────────────────────────────── + @Override + public TaskHandle> map(String name, Collection items, Class type, MapFunction fn) { + return map(name, items, type, fn, MapConfig.builder().build()); + } + + @Override + public TaskHandle> map( + String name, Collection items, Class type, MapFunction fn, MapConfig config) { + return map(name, deps -> items, type, fn, config); + } + + @Override + public TaskHandle> map( + String name, Function> items, Class type, MapFunction fn) { + return map(name, items, type, fn, MapConfig.builder().build()); + } + + @Override + public TaskHandle> map( + String name, Function> items, Class type, MapFunction fn, MapConfig config) { + var typeToken = TypeToken.get(type); + TaskExecutor> exec = (ctx, operation, deps) -> DurableMapOperation.mapAsync( + new ReservedOperationContext(ctx, name, operation), + name, + items.apply(deps), + typeToken, + fn, + config.toOperationConfig()); + return register(new TaskHandleImpl<>(name, TaskKind.MAP, exec, config)); + } + + // ── parallel ────────────────────────────────────────────────────────────── + @Override + public TaskHandle parallel(String name, Consumer branches) { + return parallel(name, branches, ParallelConfig.builder().build()); + } + + @Override + public TaskHandle parallel( + String name, Consumer branches, ParallelConfig config) { + recordResultType(name, TypeToken.get(ParallelResult.class)); + TaskExecutor exec = (ctx, operation, deps) -> { + ParallelDurableFuture p = DurableParallelOperation.parallel( + new ReservedOperationContext(ctx, name, operation), name, config.toOperationConfig()); + branches.accept(p); + return p; + }; + return register(new TaskHandleImpl<>(name, TaskKind.PARALLEL, exec, config)); + } + + // ── nested dag ────────────────────────────────────────────────────────────── + @Override + public TaskHandle dag(String name, Consumer register) { + return dag(name, register, DagConfig.builder().build()); + } + + @Override + public TaskHandle dag(String name, Consumer register, DagConfig config) { + // Register and validate the nested graph during the parent's registration phase so graph errors surface at + // the top-level dag() call site before any extension operation is reserved. + DagContextImpl nested = registerAndValidate(register); + nestedContexts.put(name, nested); + TaskExecutor exec = (ctx, operation, deps) -> start(ctx, operation, nested, config); + return register(new TaskHandleImpl<>(name, TaskKind.DAG, exec, config)); + } +} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutionOutcome.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutionOutcome.java new file mode 100644 index 000000000..44b144bdb --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutionOutcome.java @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import java.util.List; +import java.util.Map; +import software.amazon.lambda.durable.dag.DagCompletionReason; +import software.amazon.lambda.durable.dag.TaskExecution; + +/** + * Internal result of running the DAG scheduler: the terminal state of every task that reached a terminal state (keyed + * by name, in registration order), the names of tasks that were started but not terminal at early completion, why the + * DAG finished, and the number of registered tasks. + * + * @param results terminal task executions keyed by name + * @param startedTaskNames names of tasks launched but not terminal when the scheduler stopped early (bounded by + * {@code maxConcurrency}); empty on a full drain + * @param completionReason why the DAG finished + * @param totalCount number of registered tasks (fixed; independent of early completion / never-started tasks, per spec + * §2.8) + */ +public record DagExecutionOutcome( + Map> results, + List startedTaskNames, + DagCompletionReason completionReason, + int totalCount) {} diff --git a/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutor.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutor.java new file mode 100644 index 000000000..c5440189a --- /dev/null +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutor.java @@ -0,0 +1,366 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.config.CompletionConfig; +import software.amazon.lambda.durable.dag.CustomDagCompletion; +import software.amazon.lambda.durable.dag.DagCompletionConfig; +import software.amazon.lambda.durable.dag.DagCompletionDecision; +import software.amazon.lambda.durable.dag.DagCompletionItemStatus; +import software.amazon.lambda.durable.dag.DagCompletionOutcome; +import software.amazon.lambda.durable.dag.DagCompletionReason; +import software.amazon.lambda.durable.dag.DagCompletionStatus; +import software.amazon.lambda.durable.dag.DagConfig; +import software.amazon.lambda.durable.dag.DagPredicateException; +import software.amazon.lambda.durable.dag.DagTaskError; +import software.amazon.lambda.durable.dag.Deps; +import software.amazon.lambda.durable.dag.SkipReason; +import software.amazon.lambda.durable.dag.TaskExecution; +import software.amazon.lambda.durable.dag.TaskHandle; +import software.amazon.lambda.durable.dag.TaskStatus; +import software.amazon.lambda.durable.dag.ThresholdDagCompletion; +import software.amazon.lambda.durable.dag.TriggerRule; +import software.amazon.lambda.durable.exception.UnrecoverableDurableExecutionException; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.extension.ExtensionOperation; + +/** + * Topological DAG scheduler. Runs on the DAG child-context thread, reserves each task under the stable local ID + * {@code "DAG_NODE_T_" + name}, enforces {@code maxConcurrency}, and awaits results via {@link DurableFuture#get()}. + * + *