From db491089086b19795b949f75b998f8cccd81e83a Mon Sep 17 00:00:00 2001
From: Frank Chen <65260095+zhongkechen@users.noreply.github.com>
Date: Mon, 10 Aug 2026 12:32:09 -0700
Subject: [PATCH 1/3] feat(dag): rebase experimental DAG support onto extension
SPI
---
.../src/main/java/dag/DagCallback.java | 63 +
.../src/main/java/dag/DagChild.java | 49 +
.../src/main/java/dag/DagCompensate.java | 64 +
.../src/main/java/dag/DagCompensation.java | 49 +
.../main/java/dag/DagConcurrentOverlap.java | 95 ++
.../main/java/dag/DagConcurrentSuspend.java | 50 +
.../src/main/java/dag/DagDiamond.java | 54 +
.../src/main/java/dag/DagIdStability.java | 73 +
.../src/main/java/dag/DagInvoke.java | 48 +
.../src/main/java/dag/DagLargePayload.java | 91 ++
.../src/main/java/dag/DagMap.java | 56 +
.../src/main/java/dag/DagNested.java | 53 +
.../main/java/dag/DagNestedLargePayload.java | 116 ++
.../src/main/java/dag/DagParallel.java | 59 +
.../src/main/java/dag/DagRetry.java | 61 +
.../src/main/java/dag/DagRulesEngine.java | 75 +
.../src/main/java/dag/DagRunIf.java | 55 +
.../src/main/java/dag/DagRunIfAbort.java | 52 +
.../src/main/java/dag/DagSummary.java | 30 +
.../main/java/dag/DagWaitForCondition.java | 55 +
.../src/main/java/dag/DagWaitResume.java | 42 +
conformance-tests/template_dag.yaml | 388 +++++
docs/DAG_STATUS_JAVA.md | 31 +
docs/core/dag.md | 200 +++
.../examples/dag/DagCompensationExample.java | 47 +
.../examples/dag/DagDiamondExample.java | 33 +
.../durable/examples/dag/DagRunIfExample.java | 31 +
.../examples/dag/DagWaitResumeExample.java | 37 +
.../examples/CloudBasedIntegrationTest.java | 65 +
.../lambda/durable/DagConformanceTest.java | 1029 +++++++++++++
.../lambda/durable/DagIntegrationTest.java | 1281 +++++++++++++++++
.../durable/annotations/Experimental.java | 24 +
.../durable/dag/CustomDagCompletion.java | 23 +
.../durable/dag/DagCallbackSubmitter.java | 19 +
.../lambda/durable/dag/DagChildFunction.java | 20 +
.../durable/dag/DagCompletionConfig.java | 64 +
.../durable/dag/DagCompletionDecision.java | 33 +
.../durable/dag/DagCompletionItemStatus.java | 20 +
.../durable/dag/DagCompletionOutcome.java | 19 +
.../durable/dag/DagCompletionReason.java | 28 +
.../durable/dag/DagCompletionStatus.java | 30 +
.../durable/dag/DagConditionFunction.java | 22 +
.../amazon/lambda/durable/dag/DagConfig.java | 93 ++
.../amazon/lambda/durable/dag/DagContext.java | 110 ++
.../dag/DagCyclicDependencyException.java | 18 +
.../dag/DagDuplicateTaskException.java | 18 +
.../lambda/durable/dag/DagException.java | 24 +
.../durable/dag/DagExecutionException.java | 37 +
.../dag/DagInvalidDependencyException.java | 18 +
.../dag/DagInvalidTaskNameException.java | 19 +
.../lambda/durable/dag/DagOperations.java | 43 +
.../durable/dag/DagPayloadFunction.java | 17 +
.../durable/dag/DagPredicateException.java | 79 +
.../amazon/lambda/durable/dag/DagResult.java | 73 +
.../lambda/durable/dag/DagStep1Function.java | 22 +
.../lambda/durable/dag/DagStep2Function.java | 23 +
.../lambda/durable/dag/DagStep3Function.java | 24 +
.../lambda/durable/dag/DagStepFunction.java | 19 +
.../lambda/durable/dag/DagTaskError.java | 57 +
.../amazon/lambda/durable/dag/Deps.java | 37 +
.../amazon/lambda/durable/dag/SkipReason.java | 19 +
.../lambda/durable/dag/TaskExecution.java | 33 +
.../amazon/lambda/durable/dag/TaskHandle.java | 58 +
.../amazon/lambda/durable/dag/TaskStatus.java | 23 +
.../durable/dag/ThresholdDagCompletion.java | 16 +
.../lambda/durable/dag/TriggerRule.java | 30 +
.../durable/dag/internal/DagContextImpl.java | 393 +++++
.../dag/internal/DagExecutionOutcome.java | 26 +
.../durable/dag/internal/DagExecutor.java | 366 +++++
.../durable/dag/internal/DagResultImpl.java | 243 ++++
.../durable/dag/internal/DagResultSerDes.java | 233 +++
.../durable/dag/internal/DagResultTypes.java | 54 +
.../durable/dag/internal/DagValidator.java | 136 ++
.../lambda/durable/dag/internal/DepsImpl.java | 57 +
.../dag/internal/SerializedDagResult.java | 53 +
.../dag/internal/SerializedResultKind.java | 45 +
.../dag/internal/SerializedTaskExecution.java | 37 +
.../durable/dag/internal/TaskExecutor.java | 28 +
.../durable/dag/internal/TaskHandleImpl.java | 105 ++
.../lambda/durable/dag/internal/TaskKind.java | 16 +
.../dag/internal/TriggerRuleEvaluator.java | 43 +
.../operation/DurableMapOperation.java | 48 +-
.../operation/DurableParallelOperation.java | 21 +-
.../DurableWaitForConditionOperation.java | 37 +-
.../dag/DagExecutionExceptionTest.java | 47 +
.../lambda/durable/dag/DagOperationsTest.java | 142 ++
.../dag/DagPredicateExceptionTest.java | 55 +
.../internal/DagEnvelopeConvergenceTest.java | 255 ++++
.../durable/dag/internal/DagResultTest.java | 228 +++
.../dag/internal/DagValidatorTest.java | 88 ++
.../durable/dag/internal/TaskHandleTest.java | 126 ++
.../internal/TriggerRuleEvaluatorTest.java | 62 +
.../execution/DurableExecutionTest.java | 72 +
...DurableMapOperationImplementationTest.java | 72 +
...leParallelOperationImplementationTest.java | 21 +
...rConditionOperationImplementationTest.java | 28 +
96 files changed, 8767 insertions(+), 14 deletions(-)
create mode 100644 conformance-tests/src/main/java/dag/DagCallback.java
create mode 100644 conformance-tests/src/main/java/dag/DagChild.java
create mode 100644 conformance-tests/src/main/java/dag/DagCompensate.java
create mode 100644 conformance-tests/src/main/java/dag/DagCompensation.java
create mode 100644 conformance-tests/src/main/java/dag/DagConcurrentOverlap.java
create mode 100644 conformance-tests/src/main/java/dag/DagConcurrentSuspend.java
create mode 100644 conformance-tests/src/main/java/dag/DagDiamond.java
create mode 100644 conformance-tests/src/main/java/dag/DagIdStability.java
create mode 100644 conformance-tests/src/main/java/dag/DagInvoke.java
create mode 100644 conformance-tests/src/main/java/dag/DagLargePayload.java
create mode 100644 conformance-tests/src/main/java/dag/DagMap.java
create mode 100644 conformance-tests/src/main/java/dag/DagNested.java
create mode 100644 conformance-tests/src/main/java/dag/DagNestedLargePayload.java
create mode 100644 conformance-tests/src/main/java/dag/DagParallel.java
create mode 100644 conformance-tests/src/main/java/dag/DagRetry.java
create mode 100644 conformance-tests/src/main/java/dag/DagRulesEngine.java
create mode 100644 conformance-tests/src/main/java/dag/DagRunIf.java
create mode 100644 conformance-tests/src/main/java/dag/DagRunIfAbort.java
create mode 100644 conformance-tests/src/main/java/dag/DagSummary.java
create mode 100644 conformance-tests/src/main/java/dag/DagWaitForCondition.java
create mode 100644 conformance-tests/src/main/java/dag/DagWaitResume.java
create mode 100644 conformance-tests/template_dag.yaml
create mode 100644 docs/DAG_STATUS_JAVA.md
create mode 100644 docs/core/dag.md
create mode 100644 examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagCompensationExample.java
create mode 100644 examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagDiamondExample.java
create mode 100644 examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagRunIfExample.java
create mode 100644 examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagWaitResumeExample.java
create mode 100644 sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagConformanceTest.java
create mode 100644 sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/annotations/Experimental.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagContext.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagException.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagOperations.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagResult.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/Deps.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutionOutcome.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutor.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultImpl.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultSerDes.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultTypes.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagValidator.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DepsImpl.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedDagResult.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedResultKind.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedTaskExecution.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TaskExecutor.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TaskHandleImpl.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TaskKind.java
create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluator.java
create mode 100644 sdk/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java
create mode 100644 sdk/src/test/java/software/amazon/lambda/durable/dag/DagOperationsTest.java
create mode 100644 sdk/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java
create mode 100644 sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java
create mode 100644 sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java
create mode 100644 sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java
create mode 100644 sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java
create mode 100644 sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java
diff --git a/conformance-tests/src/main/java/dag/DagCallback.java b/conformance-tests/src/main/java/dag/DagCallback.java
new file mode 100644
index 000000000..85dd5ef28
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagCallback.java
@@ -0,0 +1,63 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-11: DAG task that is a callback / wait-for-callback (flat callback container under the DAG).
+ *
+ *
pre(step->"ready") -> cb(callback[pre]) -> post(step[cb]=cb+"_done"). The submitter receives the
+ * generated callback id and does nothing durable (same as the 7-1 wait-for-callback handler); the conformance runner
+ * completes the callback externally with a success payload. maxConcurrency=1 for a deterministic topological order.
+ * Every task succeeds → ALL_COMPLETED. This scenario suspends until the external callback arrives. Returns the
+ * canonical summary from 10-11.yaml.
+ */
+public class DagCallback extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "callbackdag",
+ d -> {
+ var pre = d.step("pre", String.class, (deps, s) -> "ready");
+ var cb = d.callback("cb", String.class, (deps, callbackId, stepCtx) -> {
+ // Submitter receives the callback id; nothing durable to do.
+ })
+ .reads(pre);
+ d.step(
+ "post",
+ String.class,
+ (deps, s) -> stripQuotes(deps.get(cb).orElseThrow()) + "_done")
+ .reads(cb);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("cb", stripQuotes((String) r.getResult("cb").orElseThrow()));
+ out.put("post", r.getResult("post").orElseThrow());
+ return out;
+ }
+
+ /**
+ * Strips a single pair of surrounding double-quote characters from the callback result if present. The default
+ * callback deserializer is documented as returning the raw payload text (quotes included); the runner's payload is
+ * alphanumeric so this normalization is unambiguous.
+ */
+ private static String stripQuotes(String value) {
+ if (value != null && value.length() >= 2 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"') {
+ return value.substring(1, value.length() - 1);
+ }
+ return value;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagChild.java b/conformance-tests/src/main/java/dag/DagChild.java
new file mode 100644
index 000000000..a878c63b1
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagChild.java
@@ -0,0 +1,49 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-5: DAG task that is a runInChildContext (flat child container under the DAG).
+ *
+ * seed(step->1) -> group(runInChildContext[seed]: inner-a=seed+1=2, inner-b=seed+2=3, returns 5) ->
+ * done(step[group]=group*2=10). maxConcurrency=1 for a deterministic topological order. Every task succeeds →
+ * ALL_COMPLETED. Returns the canonical summary from 10-5.yaml.
+ */
+public class DagChild extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "childdag",
+ d -> {
+ var seed = d.step("seed", Integer.class, (deps, s) -> 1);
+ var group = d.runInChildContext("group", Integer.class, (deps, childCtx) -> {
+ int seedVal = deps.get(seed).orElseThrow();
+ int a = childCtx.step("inner-a", Integer.class, s -> seedVal + 1);
+ int b = childCtx.step("inner-b", Integer.class, s -> seedVal + 2);
+ return a + b;
+ })
+ .reads(seed);
+ d.step("done", Integer.class, (deps, s) -> deps.get(group).orElseThrow() * 2)
+ .reads(group);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("group", r.getResult("group").orElseThrow());
+ out.put("done", r.getResult("done").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagCompensate.java b/conformance-tests/src/main/java/dag/DagCompensate.java
new file mode 100644
index 000000000..11a1a5bde
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagCompensate.java
@@ -0,0 +1,64 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+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.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+import software.amazon.lambda.durable.dag.TriggerRule;
+import software.amazon.lambda.durable.retry.RetryStrategies;
+
+/**
+ * 10-18: compensation dependency read on a FAILED upstream is ABSENT, not present (the deps-nullability contract).
+ *
+ * DAG "compensate" with two step tasks: charge -> audit. {@code charge} is a root step that ALWAYS fails; its
+ * retry strategy is disabled (a single attempt) so it ends FAILED deterministically (exactly one StepFailed).
+ * {@code audit} depends on {@code charge} via an INLINE (typed) dependency ({@code .reads(charge)}) and uses the
+ * ALL_DONE trigger rule, so it runs even though {@code charge} FAILED and receives {@code charge} in its resolved deps.
+ * Its body reads {@code deps.get(charge)}: a dependency that did not SUCCEED resolves to an empty
+ * {@link java.util.Optional} (absent), never a stale/fabricated value, so {@code audit} returns {@code "absent"} when
+ * the Optional is empty and {@code "present"} otherwise.
+ *
+ *
The DAG drains to COMPLETED_WITH_FAILURES without throwing: {@code charge} FAILED, {@code audit} SUCCEEDED with
+ * result {@code "absent"}. Returns the canonical summary from 10-18.yaml.
+ */
+public class DagCompensate extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "compensate",
+ d -> {
+ // Single attempt (no retry) so charge ends FAILED deterministically.
+ var charge = d.step(
+ "charge",
+ String.class,
+ (deps, s) -> {
+ throw new RuntimeException("charge failed");
+ },
+ StepConfig.builder()
+ .retryStrategy(RetryStrategies.fixedDelay(1, Duration.ofSeconds(1)))
+ .build());
+ // Inline dep on charge + ALL_DONE: audit runs and reads charge. A failed dependency's
+ // value is absent (Optional.empty()), so audit returns "absent".
+ d.step("audit", String.class, (deps, s) -> deps.get(charge).isEmpty() ? "absent" : "present")
+ .reads(charge)
+ .triggerRule(TriggerRule.ALL_DONE);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("audit", r.getResult("audit").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagCompensation.java b/conformance-tests/src/main/java/dag/DagCompensation.java
new file mode 100644
index 000000000..d7378efc0
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagCompensation.java
@@ -0,0 +1,49 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+import software.amazon.lambda.durable.dag.TriggerRule;
+
+/**
+ * 10-2: DAG trigger-rule compensation (COMPLETED_WITH_FAILURES).
+ *
+ * charge (root) always fails. fulfill uses the default ALL_SUCCESS trigger, so it is SKIPPED. refund uses ALL_FAILED
+ * and runs ("refunded"). audit uses ALL_DONE and runs ("logged"). charge exhausts the default retry policy before
+ * failing terminally, so the DAG drains to COMPLETED_WITH_FAILURES without throwing. Returns the canonical summary from
+ * 10-2.yaml.
+ */
+public class DagCompensation extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "compensation",
+ d -> {
+ var charge = d.step("charge", String.class, (deps, s) -> {
+ throw new RuntimeException("payment declined");
+ });
+ d.step("fulfill", String.class, (deps, s) -> "fulfilled").after(charge);
+ d.step("refund", String.class, (deps, s) -> "refunded")
+ .after(charge)
+ .triggerRule(TriggerRule.ALL_FAILED);
+ d.step("audit", String.class, (deps, s) -> "logged")
+ .after(charge)
+ .triggerRule(TriggerRule.ALL_DONE);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagConcurrentOverlap.java b/conformance-tests/src/main/java/dag/DagConcurrentOverlap.java
new file mode 100644
index 000000000..e5b93254b
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagConcurrentOverlap.java
@@ -0,0 +1,95 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-13: DAG real overlap inside one invocation (maxConcurrency unset).
+ *
+ * root(step->1) -> {slow(step[root], sleeps ~2s, "S"), fast(step[root], sleeps ~200ms, "F")}; slow is
+ * registered first. afterSlow(step[slow]="Ss") is registered before afterFast(step[fast]="Ff") so afterFast becomes
+ * ready — and starts — FIRST, inverting registration order versus start order. merge(step[afterSlow, afterFast]) fans
+ * in to "SsFf". Because tasks finish out of registration order, a counter-based ID regression would look for a
+ * checkpoint that is not there and fail replay consistency, so only order-invariant outcomes are asserted (see
+ * CONCURRENCY_COVERAGE_CONTRACT).
+ *
+ *
Peak concurrency is instrumented on the user-executor threads that run slow/fast via an atomic active-count and a
+ * running max, returned as {@code peakConcurrency} so the scenario cannot silently become vacuous if the scheduler is
+ * ever serialised.
+ */
+public class DagConcurrentOverlap extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ // Shared, thread-safe peak-concurrency instrumentation: slow and fast run on user-executor threads, so we
+ // track the maximum simultaneously-active body count with atomics (not plain ints).
+ final AtomicInteger active = new AtomicInteger(0);
+ final AtomicInteger peak = new AtomicInteger(0);
+
+ DagResult r = dag("overlapdag", d -> {
+ var root = d.step("root", Integer.class, (deps, s) -> 1);
+ var slow = d.step("slow", String.class, (deps, s) -> {
+ enter(active, peak);
+ 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) -> {
+ enter(active, peak);
+ 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);
+ });
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("merge", r.getResult("merge").orElseThrow());
+ out.put("peakConcurrency", peak.get());
+ return out;
+ }
+
+ /** Records entry to a concurrent task body and updates the running peak (thread-safe). */
+ private static void enter(AtomicInteger active, AtomicInteger peak) {
+ int now = active.incrementAndGet();
+ peak.accumulateAndGet(now, Math::max);
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagConcurrentSuspend.java b/conformance-tests/src/main/java/dag/DagConcurrentSuspend.java
new file mode 100644
index 000000000..7dd9858e3
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagConcurrentSuspend.java
@@ -0,0 +1,50 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-14: DAG inverted readiness across a suspend (maxConcurrency unset).
+ *
+ * root(step->1) -> {slow(wait 8s), fast(wait 2s)}; slow is registered first. Both waits start in the first
+ * invocation, so the invocation suspends with TWO tasks in flight and resumes twice. afterSlow(step .after(slow)="S")
+ * is registered before afterFast(step .after(fast)="F"), so afterFast becomes ready one invocation earlier — the
+ * downstream pair starts in the reverse of registration order across different invocations. merge(step[afterSlow,
+ * afterFast]) fans in to "SF". This is the replay-flip case: a counter-based ID regression cannot survive resuming into
+ * a mid-DAG state with concurrent in-flight tasks. Timers (not races) decide the order, so the outcome is
+ * deterministic; the ~6s gap between the waits must not shrink below ~4s. See CONCURRENCY_COVERAGE_CONTRACT.
+ */
+public class DagConcurrentSuspend extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag("suspenddag", d -> {
+ var root = d.step("root", Integer.class, (deps, s) -> 1);
+ var slow = d.wait("slow", Duration.ofSeconds(8)).after(root);
+ var fast = d.wait("fast", Duration.ofSeconds(2)).after(root);
+ var afterSlow = d.step("afterSlow", String.class, (deps, s) -> "S").after(slow);
+ var afterFast = d.step("afterFast", String.class, (deps, s) -> "F").after(fast);
+ d.step(
+ "merge",
+ String.class,
+ (deps, s) -> deps.get(afterSlow).orElseThrow()
+ + deps.get(afterFast).orElseThrow())
+ .reads(afterSlow, afterFast);
+ });
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("merge", r.getResult("merge").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagDiamond.java b/conformance-tests/src/main/java/dag/DagDiamond.java
new file mode 100644
index 000000000..12ff26dd5
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagDiamond.java
@@ -0,0 +1,54 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-1: DAG diamond fan-out/fan-in (all tasks complete).
+ *
+ * fetch(10) -> {ta(=fetch+1=11), tb(=fetch*2=20)} -> merge(=ta+tb=31). maxConcurrency=1 for a deterministic
+ * topological order. Every task succeeds → ALL_COMPLETED. Returns the canonical summary from 10-1.yaml.
+ */
+public class DagDiamond extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "diamond",
+ d -> {
+ var fetch = d.step("fetch", Integer.class, (deps, s) -> 10);
+ var ta = d.step(
+ "ta",
+ Integer.class,
+ (deps, s) -> deps.get(fetch).orElseThrow() + 1)
+ .reads(fetch);
+ var tb = d.step(
+ "tb",
+ Integer.class,
+ (deps, s) -> deps.get(fetch).orElseThrow() * 2)
+ .reads(fetch);
+ d.step(
+ "merge",
+ Integer.class,
+ (deps, s) -> deps.get(ta).orElseThrow()
+ + deps.get(tb).orElseThrow())
+ .reads(ta, tb);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("merge", r.getResult("merge").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagIdStability.java b/conformance-tests/src/main/java/dag/DagIdStability.java
new file mode 100644
index 000000000..c9cee4e9a
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagIdStability.java
@@ -0,0 +1,73 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-20: DAG task-id stability across independently forced completion orders.
+ *
+ * Identical shape to 10-13's overlap (DagConcurrentOverlap) — root -> {a, b} -> {afterA, afterB} ->
+ * merge, maxConcurrency unset — except which sibling sleeps longer is driven by the input's {@code swap} flag:
+ * swap=false makes {@code a} finish first; swap=true makes {@code b} finish first. Both invocations register the SAME
+ * task names in the SAME order every time — only the RUNTIME completion order changes.
+ *
+ *
This is the harness-level counterpart to 10-13: 10-13 proves out-of-order completion doesn't fail the execution
+ * (an INDIRECT proof of name-based ids, since a counter-based scheme would trip the SDK's own replay-consistency
+ * check). This scenario is invoked TWICE by a dedicated script ({@code id_stability.py}, not the normal
+ * single-invocation validator) with swap flipped between runs, and asserts each task's {@code Id} field in the captured
+ * execution history is IDENTICAL across both runs — the direct proof that ids are derived from the task name, not
+ * from completion order or a counter.
+ */
+public class DagIdStability extends DurableHandler, Map> {
+
+ @Override
+ public Map handleRequest(Map input, DurableContext context) {
+ boolean swap = input != null && Boolean.TRUE.equals(input.get("swap"));
+
+ DagResult r = dag("idstabilitydag", d -> {
+ var root = d.step("root", Integer.class, (deps, s) -> 1);
+ var a = d.step("a", String.class, (deps, s) -> {
+ sleepQuietly(swap ? 2000 : 200);
+ return "A";
+ })
+ .after(root);
+ var b = d.step("b", String.class, (deps, s) -> {
+ sleepQuietly(swap ? 200 : 2000);
+ return "B";
+ })
+ .after(root);
+ var afterA = d.step("afterA", String.class, (deps, s) -> deps.get(a).orElseThrow() + "a")
+ .reads(a);
+ var afterB = d.step("afterB", String.class, (deps, s) -> deps.get(b).orElseThrow() + "b")
+ .reads(b);
+ d.step(
+ "merge",
+ String.class,
+ (deps, s) -> deps.get(afterA).orElseThrow()
+ + deps.get(afterB).orElseThrow())
+ .reads(afterA, afterB);
+ });
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("merge", r.getResult("merge").orElseThrow());
+ return out;
+ }
+
+ private static void sleepQuietly(long millis) {
+ try {
+ Thread.sleep(millis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagInvoke.java b/conformance-tests/src/main/java/dag/DagInvoke.java
new file mode 100644
index 000000000..e36fdcfe6
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagInvoke.java
@@ -0,0 +1,48 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-10: DAG task that is an invoke of another Lambda (flat invoke op under the DAG).
+ *
+ * prep(step->21) -> call(invoke[prep]=echo(21)=21) -> done(step[call]=call*2=42). The invoke target is the
+ * echo function named by the {@code TARGET_FUNCTION_NAME} env var, so {@code call} resolves to the payload it was sent
+ * ({@code prep}=21). maxConcurrency=1 for a deterministic topological order. Every task succeeds → ALL_COMPLETED. This
+ * scenario suspends and resumes (the invoke completes in a later invocation). Returns the canonical summary from
+ * 10-10.yaml.
+ */
+public class DagInvoke extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ String functionName = System.getenv("TARGET_FUNCTION_NAME");
+ DagResult r = dag(
+ "invokedag",
+ d -> {
+ var prep = d.step("prep", Integer.class, (deps, s) -> 21);
+ var call = d.invoke("call", functionName, Integer.class, deps -> deps.get(prep)
+ .orElseThrow())
+ .reads(prep);
+ d.step("done", Integer.class, (deps, s) -> deps.get(call).orElseThrow() * 2)
+ .reads(call);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("call", r.getResult("call").orElseThrow());
+ out.put("done", r.getResult("done").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagLargePayload.java b/conformance-tests/src/main/java/dag/DagLargePayload.java
new file mode 100644
index 000000000..e10e47996
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagLargePayload.java
@@ -0,0 +1,91 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-15: DAG with a large aggregate result offloaded and replayed across a suspend.
+ *
+ * Eight step roots {@code p1..p8}, each returning 51200 repetitions of its own letter ({@code p1}="a"×51200 ..
+ * {@code p8}="h"×51200). The aggregate is ~410KB, comfortably over the 256KB checkpoint threshold, while every
+ * individual task result stays well under it — so only the aggregate is offloaded (checkpointed with an empty payload
+ * plus the ReplayChildren flag). {@code maxConcurrency=1} keeps it deterministic.
+ *
+ *
The offload alone is not the interesting path: the reconstruct-vs-re-execute divergence between the SDKs
+ * (TypeScript reconstructs from an SDK-owned {@code DagSummary} envelope; Python, Java and Go re-execute the DAG child
+ * body via ReplayChildren) only fires when a succeeded container is replayed . So the handler suspends AFTER the
+ * DAG resolves, via a 2s wait, forcing the next invocation to replay the completed container.
+ *
+ *
Flow: (1) {@code dag(...)} resolves the ~410KB aggregate; (2) a checkpointed step computes {@code digestBefore} =
+ * {@code "::"} → {@code "8:409600:abcdefgh"} from the DagResult, so
+ * it survives the suspend; (3) a 2s wait ends the invocation; (4) after the resume the same digest is recomputed from
+ * the REPLAYED DagResult → {@code digestAfter}. The language-neutral assertion is {@code digestBefore == digestAfter ==
+ * "8:409600:abcdefgh"} ({@code match=true}): the aggregate survived the offload and came back identical through
+ * whichever replay strategy the SDK uses. Outcome-only — no history is pinned, because the container's succeeded
+ * payload legitimately differs across SDKs. See LARGE_PAYLOAD_CONTRACT / 10-15.yaml.
+ */
+public class DagLargePayload extends DurableHandler> {
+
+ /** Each task returns this many repetitions of its own letter — under the per-op limit, over it in aggregate. */
+ private static final int PER_TASK = 51200;
+
+ /** Eight tasks p1..p8 → letters a..h; aggregate = 8 × 51200 = 409600 chars ≈ 410KB > 256KB. */
+ private static final int TASK_COUNT = 8;
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "bigdag",
+ d -> {
+ for (int i = 1; i <= TASK_COUNT; i++) {
+ final String letter = String.valueOf((char) ('a' + (i - 1)));
+ d.step("p" + i, String.class, (deps, s) -> letter.repeat(PER_TASK));
+ }
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ // Digest of the aggregate BEFORE the suspend. A step, so it is checkpointed and survives the wait; on the
+ // resume it fast-paths from its checkpoint, carrying the pre-suspend value forward for comparison.
+ String digestBefore = context.step("digestBefore", String.class, s -> digest(r));
+
+ // The whole point: end this invocation so the next one replays the completed (offloaded) DAG container.
+ context.wait("suspend", Duration.ofSeconds(2));
+
+ // Recomputed from the REPLAYED DagResult after the resume (envelope reconstruction in JS; native child-body
+ // re-execution here in Java). Equality with digestBefore proves the aggregate round-tripped intact.
+ String digestAfter = digest(r);
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("counts", DagSummary.counts(r));
+ out.put("digestBefore", digestBefore);
+ out.put("digestAfter", digestAfter);
+ out.put("match", digestBefore.equals(digestAfter));
+ return out;
+ }
+
+ /**
+ * Language-neutral digest of the aggregate: {@code "::"}. For
+ * this graph it is exactly {@code "8:409600:abcdefgh"}. Never returns the payload itself, keeping the summary
+ * small.
+ */
+ private static String digest(DagResult r) {
+ long totalLength = 0;
+ StringBuilder firstChars = new StringBuilder();
+ for (int i = 1; i <= TASK_COUNT; i++) {
+ String v = (String) r.getResult("p" + i).orElseThrow();
+ totalLength += v.length();
+ firstChars.append(v.charAt(0));
+ }
+ return r.totalCount() + ":" + totalLength + ":" + firstChars;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagMap.java b/conformance-tests/src/main/java/dag/DagMap.java
new file mode 100644
index 000000000..5c55d9798
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagMap.java
@@ -0,0 +1,56 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.config.MapConfig;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+import software.amazon.lambda.durable.model.MapResult;
+
+/**
+ * 10-6: DAG task that is a map over a fixed item list (flat map container under the DAG).
+ *
+ * squares(map [1,2] each item->item*item = [1,4]) -> sum(step[squares]=1+4=5). maxConcurrency=1 at both the
+ * DAG and map levels for a deterministic history. Every task succeeds → ALL_COMPLETED. Returns the canonical summary
+ * from 10-6.yaml.
+ */
+public class DagMap extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "mapdag",
+ d -> {
+ var squares = d.map(
+ "squares",
+ List.of(1, 2),
+ Integer.class,
+ (item, index, ctx) -> ctx.step(null, Integer.class, s -> item * item),
+ MapConfig.builder().maxConcurrency(1).build());
+ d.step("sum", Integer.class, (deps, s) -> {
+ MapResult m = deps.get(squares).orElseThrow();
+ return m.results().stream()
+ .filter(Objects::nonNull)
+ .mapToInt(Integer::intValue)
+ .sum();
+ })
+ .reads(squares);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("sum", r.getResult("sum").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagNested.java b/conformance-tests/src/main/java/dag/DagNested.java
new file mode 100644
index 000000000..b168a86ab
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagNested.java
@@ -0,0 +1,53 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-9: DAG task that is itself a nested DAG / sub-dag (flat nested Dag container under the outer DAG).
+ *
+ * pre(step->1) -> sub(nested dag[pre]: n1->2, n2[n1]=n1+3=5) -> post(step[sub]=sub.n2*10=50).
+ * maxConcurrency=1 at both DAG levels for a deterministic topological order. Every task succeeds → ALL_COMPLETED at
+ * both levels. Returns the canonical summary from 10-9.yaml.
+ */
+public class DagNested extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "outerdag",
+ d -> {
+ var pre = d.step("pre", Integer.class, (deps, s) -> 1);
+ var sub = d.dag("sub", nd -> {
+ var n1 = nd.step("n1", Integer.class, (deps, s) -> 2);
+ nd.step(
+ "n2",
+ Integer.class,
+ (deps, s) -> deps.get(n1).orElseThrow() + 3)
+ .reads(n1);
+ })
+ .after(pre);
+ d.step("post", Integer.class, (deps, s) -> {
+ DagResult nested = deps.get(sub).orElseThrow();
+ return ((Number) nested.getResult("n2").orElseThrow()).intValue() * 10;
+ })
+ .reads(sub);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("post", r.getResult("post").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagNestedLargePayload.java b/conformance-tests/src/main/java/dag/DagNestedLargePayload.java
new file mode 100644
index 000000000..8f6456063
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagNestedLargePayload.java
@@ -0,0 +1,116 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-17: the untested intersection of nesting and large payloads — a nested DAG whose OWN aggregate is offloaded, then
+ * replayed across a suspend.
+ *
+ * Modeled on 10-15 / {@link DagLargePayload}: the {@code wait} sits OUTSIDE the outer DAG so the DAG completes in
+ * the first invocation and the NEXT invocation replays BOTH completed (offloaded) containers.
+ *
+ *
Structure:
+ *
+ *
+ * Outer container {@code outernested} ({@code maxConcurrency=1}) has exactly ONE task: {@code inner} — a nested
+ * dag task, itself {@code maxConcurrency=1}, with six step tasks {@code p1..p6}, each returning a single distinct
+ * letter repeated 51200 times ({@code p1}="a"×51200 .. {@code p6}="f"×51200). Six × 51200 = 307200 chars ≈ 307KB,
+ * comfortably over the 256KB checkpoint limit, so the inner aggregate offloads — and because the outer embeds the
+ * inner result in full, the outer offloads too.
+ * At HANDLER level, after the DAG returns: {@code digestBefore} — a checkpointed step that reads the inner
+ * {@link DagResult} and computes a compact digest {@code "::
+ * "} → {@code "6:307200:abcdef"}. As a checkpointed step it survives the
+ * suspend.
+ * An outer 2s {@code wait} that ends the invocation, forcing the next one to replay both completed containers.
+ * {@code digestAfter} — recomputes the identical digest from the REPLAYED inner result after the resume.
+ *
+ *
+ * The decisive, language-neutral assertion is {@code digestBefore == digestAfter == "6:307200:abcdef"} with
+ * {@code match: true}, {@code innerReason: ALL_COMPLETED} and {@code innerCounts: [6,0,0,6]}. That proves the inner
+ * per-task detail survived the offload of BOTH containers. Under the bug the inner would come back empty, so the digest
+ * after replay would differ — {@code innerReason} would still read {@code ALL_COMPLETED} from a fabricated result,
+ * which is exactly why the digest, not the reason, is the decisive check. Outcome-only — no history/event count is
+ * pinned until MEASURED from a real cloud run (see 10-15 / DagLargePayload). See NESTED_OFFLOAD_CONTRACT / 10-17.yaml.
+ */
+public class DagNestedLargePayload extends DurableHandler> {
+
+ /**
+ * Each inner task returns this many repetitions of its own letter — under the per-op limit, over it in aggregate.
+ */
+ private static final int PER_TASK = 51200;
+
+ /** Six inner tasks p1..p6 → letters a..f; inner aggregate = 6 × 51200 = 307200 chars ≈ 307KB > 256KB. */
+ private static final int INNER_TASK_COUNT = 6;
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ // Outer container holds exactly ONE task: the nested `inner` dag. The wait lives at HANDLER level (below), so
+ // the outer DAG completes in the first invocation and the next one replays both completed containers.
+ DagResult r = dag(
+ "outernested",
+ d -> d.dag(
+ "inner",
+ nd -> {
+ for (int i = 1; i <= INNER_TASK_COUNT; i++) {
+ final String letter = String.valueOf((char) ('a' + (i - 1)));
+ nd.step("p" + i, String.class, (deps, s) -> letter.repeat(PER_TASK));
+ }
+ },
+ DagConfig.builder().maxConcurrency(1).build()),
+ DagConfig.builder().maxConcurrency(1).build());
+
+ // Digest of the inner aggregate BEFORE the suspend. A checkpointed step, so it survives the wait; on the resume
+ // it fast-paths from its checkpoint, carrying the pre-suspend value forward for comparison.
+ String digestBefore = context.step(
+ "digestBefore",
+ String.class,
+ s -> innerDigest((DagResult) r.getResult("inner").orElseThrow()));
+
+ // The whole point: end this invocation so the next one replays both completed (offloaded) containers.
+ context.wait("settle", Duration.ofSeconds(2));
+
+ // Recomputed from the REPLAYED inner DagResult after the resume. Equality with digestBefore proves the inner
+ // per-task detail round-tripped intact through the offload of both containers.
+ DagResult inner = (DagResult) r.getResult("inner").orElseThrow();
+ String digestAfter = innerDigest(inner);
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("innerReason", inner.completionReason().name());
+ // innerCounts = [total, failed, skipped, succeeded] — the aggregate the inner offloaded envelope carries.
+ out.put(
+ "innerCounts",
+ List.of(inner.totalCount(), inner.failureCount(), inner.skippedCount(), inner.successCount()));
+ out.put("digestBefore", digestBefore);
+ out.put("digestAfter", digestAfter);
+ out.put("match", digestBefore.equals(digestAfter));
+ return out;
+ }
+
+ /**
+ * Language-neutral digest of the inner aggregate:
+ * {@code "::"}. For this graph it is exactly
+ * {@code "6:307200:abcdef"}. Never returns the payloads themselves, keeping the digest small.
+ */
+ private static String innerDigest(DagResult inner) {
+ long totalLength = 0;
+ StringBuilder firstChars = new StringBuilder();
+ for (int i = 1; i <= INNER_TASK_COUNT; 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/conformance-tests/src/main/java/dag/DagParallel.java b/conformance-tests/src/main/java/dag/DagParallel.java
new file mode 100644
index 000000000..244dbf074
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagParallel.java
@@ -0,0 +1,59 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.config.ParallelConfig;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+import software.amazon.lambda.durable.model.ParallelResult;
+
+/**
+ * 10-7: DAG task that is a parallel of two named branches (flat parallel container under the DAG).
+ *
+ * fork(parallel left->"L", right->"R") -> join(step[fork]="<succeeded>/<size>"="2/2").
+ * maxConcurrency=1 at both the DAG and parallel levels for a deterministic history. Every task succeeds →
+ * ALL_COMPLETED.
+ *
+ *
Aggregate-only join: Java cannot read parallel branch values from a step task, because {@code DurableFuture.get()}
+ * calls {@code validateCurrentThreadType()} unconditionally and any durable-op read from a step body throws
+ * {@code IllegalStateException}. Java's {@link ParallelResult} is aggregate-only by design (heterogeneous branch
+ * types), unlike the TS {@code BatchResult}. So {@code join} reads only the {@link ParallelResult} handed to it as the
+ * dep value and returns {@code "/"}. Reading child values is covered by 10-6 (map). Returns the
+ * canonical summary from 10-7.yaml.
+ */
+public class DagParallel extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "paralleldag",
+ d -> {
+ var fork = d.parallel(
+ "fork",
+ p -> {
+ p.branch("left", String.class, ctx -> ctx.step(null, String.class, s -> "L"));
+ p.branch("right", String.class, ctx -> ctx.step(null, String.class, s -> "R"));
+ },
+ ParallelConfig.builder().maxConcurrency(1).build());
+ d.step("join", String.class, (deps, s) -> {
+ ParallelResult pr = deps.get(fork).orElseThrow();
+ return pr.succeeded() + "/" + pr.size();
+ })
+ .reads(fork);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("join", r.getResult("join").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagRetry.java b/conformance-tests/src/main/java/dag/DagRetry.java
new file mode 100644
index 000000000..efb106609
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagRetry.java
@@ -0,0 +1,61 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+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.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+import software.amazon.lambda.durable.retry.RetryStrategies;
+
+/**
+ * 10-16: retry inside a DAG (a task retries and eventually succeeds).
+ *
+ * {@code flaky} is a step carrying an explicit per-task retry strategy of 3 attempts with a fixed 1-second delay
+ * (the minimum the SDK allows; no exponential backoff worth waiting on). Its body reads the 1-based attempt number from
+ * its {@link software.amazon.lambda.durable.StepContext} via {@code getAttempt()} and throws on attempts 1 and 2,
+ * returning the attempt number ({@code 3}) on the third. {@code after} depends on {@code flaky} and returns that value
+ * doubled ({@code 6}), proving a retried task's result flows downstream normally. {@code maxConcurrency=1} for a
+ * deterministic order.
+ *
+ *
The point of the scenario: {@code flaky} ends {@code SUCCEEDED} (not {@code FAILED}) and {@code after} runs rather
+ * than being skipped — which is exactly what a broken retry inside a DAG would break. Returns {@code {"flaky": 3,
+ * "after": 6}}.
+ */
+public class DagRetry extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "retrydag",
+ d -> {
+ var flaky = d.step(
+ "flaky",
+ Integer.class,
+ (deps, s) -> {
+ // getAttempt() is 1-based at runtime: fail on attempts 1 and 2, succeed on 3.
+ if (s.getAttempt() < 3) {
+ throw new RuntimeException("attempt " + s.getAttempt() + " is not yet the third");
+ }
+ return s.getAttempt();
+ },
+ StepConfig.builder()
+ .retryStrategy(RetryStrategies.fixedDelay(3, Duration.ofSeconds(1)))
+ .build());
+ d.step("after", Integer.class, (deps, s) -> deps.get(flaky).orElseThrow() * 2)
+ .reads(flaky);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("flaky", r.getResult("flaky").orElseThrow());
+ out.put("after", r.getResult("after").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagRulesEngine.java b/conformance-tests/src/main/java/dag/DagRulesEngine.java
new file mode 100644
index 000000000..ff6ad60eb
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagRulesEngine.java
@@ -0,0 +1,75 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagCompletionConfig;
+import software.amazon.lambda.durable.dag.DagCompletionDecision;
+import software.amazon.lambda.durable.dag.DagCompletionOutcome;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+import software.amazon.lambda.durable.dag.TaskStatus;
+
+/**
+ * 10-19: DAG custom result-based completion. A rules-engine predicate short-circuits the moment any task's SUCCEEDED
+ * result carries a REJECT verdict -- expressible only because the custom-completion predicate can inspect task RESULTS,
+ * not just aggregate counts.
+ *
+ * DAG "rulesengine" with max-concurrency 1 and a linear chain of three step tasks: r1 -> r2 -> r3, each
+ * returning a verdict map. r1 -> ACCEPT, r2 -> REJECT, r3 (never runs) -> ACCEPT.
+ *
+ *
The completion config is {@link DagCompletionConfig#custom}, not a threshold: after every settlement it receives a
+ * live progress snapshot and inspects every SUCCEEDED item's result for a REJECT verdict. The moment it sees one, it
+ * returns a FAILED completion decision. r3 is never started and is absent from the results map. The DAG completes with
+ * {@code CUSTOM_COMPLETION_FAILED} -- distinct from {@code COMPLETED_WITH_FAILURES}, since no individual task FAILED.
+ * {@code throwIfError()} MUST still throw in this case (the contract keys off {@code completionReason} too, not
+ * {@code failureCount} alone).
+ *
+ *
Returns the canonical summary from 10-19.yaml.
+ */
+public class DagRulesEngine extends DurableHandler> {
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Map handleRequest(Object input, DurableContext context) {
+ DagConfig 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(((Map)
+ item.result().get())
+ .get("verdict")));
+ return anyRejected
+ ? DagCompletionDecision.complete(DagCompletionOutcome.FAILED)
+ : DagCompletionDecision.continueDag();
+ }))
+ .build();
+
+ DagResult r = dag(
+ "rulesengine",
+ d -> {
+ var r1 = d.step("r1", Map.class, (deps, s) -> Map.of("verdict", "ACCEPT"));
+ var r2 = d.step("r2", Map.class, (deps, s) -> Map.of("verdict", "REJECT"))
+ .reads(r1);
+ d.step("r3", Map.class, (deps, s) -> Map.of("verdict", "ACCEPT"))
+ .reads(r2);
+ },
+ config);
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("counts", DagSummary.counts(r));
+ out.put("r1", r.getResult("r1").orElseThrow());
+ out.put("r2", r.getResult("r2").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagRunIf.java b/conformance-tests/src/main/java/dag/DagRunIf.java
new file mode 100644
index 000000000..1bf104539
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagRunIf.java
@@ -0,0 +1,55 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+import software.amazon.lambda.durable.dag.TaskStatus;
+
+/**
+ * 10-3: DAG per-task conditional execution (runIf).
+ *
+ * classify returns "review". publish/review/block each depend on classify and are guarded by a runIf predicate that
+ * runs the branch only when classify's result equals the branch's own name. Only review runs; publish and block are
+ * SKIPPED and emit no events. Returns the canonical summary from 10-3.yaml.
+ */
+public class DagRunIf extends DurableHandler> {
+
+ private static final String[] BRANCHES = {"publish", "review", "block"};
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "runif",
+ d -> {
+ var classify = d.step("classify", String.class, (deps, s) -> "review");
+ for (String branch : BRANCHES) {
+ final String name = branch;
+ d.step(name, String.class, (deps, s) -> name)
+ .reads(classify)
+ .runIf(deps -> name.equals(deps.get(classify).orElse(null)));
+ }
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ String branch = null;
+ for (String b : BRANCHES) {
+ if (r.getStatus(b).orElse(null) == TaskStatus.SUCCEEDED) {
+ branch = b;
+ break;
+ }
+ }
+ out.put("branch", branch);
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagRunIfAbort.java b/conformance-tests/src/main/java/dag/DagRunIfAbort.java
new file mode 100644
index 000000000..a3eb422f0
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagRunIfAbort.java
@@ -0,0 +1,52 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+import software.amazon.lambda.durable.dag.TriggerRule;
+
+/**
+ * 10-12: DAG {@code runIf} abort path on the wire.
+ *
+ * gate(step->1) -> guarded(step[gate], runIf throws "predicate boom") -> refund(step .after(guarded),
+ * ALL_FAILED). A throwing {@code runIf} is a defect in deterministic code, so the scheduler ABORTS: guarded gets no
+ * terminal state and is never invoked, refund (the ALL_FAILED compensation) never runs, and {@code dag(...)} fails with
+ * a typed {@code DagPredicateException}. The exception propagates out of this handler, so the execution FAILS — the DAG
+ * container checkpoints ContextFailed SubType=Dag after gate succeeded. maxConcurrency=1 keeps a deterministic order so
+ * this scenario retains full history assertions. See RUNIF_ABORT_CONTRACT / 10-12.yaml.
+ */
+public class DagRunIfAbort extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ // dag(...) throws DagPredicateException when guarded's runIf throws; we deliberately do NOT catch it, so the
+ // execution fails. The summary below is unreachable and exists only to mirror the sibling handlers' shape.
+ 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) -> "refunded")
+ .after(guarded)
+ .triggerRule(TriggerRule.ALL_FAILED);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagSummary.java b/conformance-tests/src/main/java/dag/DagSummary.java
new file mode 100644
index 000000000..cefa73e44
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagSummary.java
@@ -0,0 +1,30 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * Builds the canonical cross-language DAG summary fields shared by the {@code dag} conformance handlers (see
+ * {@code test-requirements/dag/10-*.yaml}): the completion reason, the per-task terminal statuses, and the
+ * {@code [succeeded, failed, skipped, total]} counts.
+ */
+final class DagSummary {
+
+ private DagSummary() {}
+
+ /** Per-task terminal status map (name -> status name), including SKIPPED tasks. */
+ static Map statuses(DagResult r) {
+ Map m = new LinkedHashMap<>();
+ r.results().forEach((name, te) -> m.put(name, te.status().name()));
+ return m;
+ }
+
+ /** The canonical {@code [succeeded, failed, skipped, total]} counts array. */
+ static List counts(DagResult r) {
+ return List.of(r.successCount(), r.failureCount(), r.skippedCount(), r.totalCount());
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagWaitForCondition.java b/conformance-tests/src/main/java/dag/DagWaitForCondition.java
new file mode 100644
index 000000000..928ba534d
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagWaitForCondition.java
@@ -0,0 +1,55 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.config.WaitForConditionConfig;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+import software.amazon.lambda.durable.model.WaitForConditionResult;
+
+/**
+ * 10-8: DAG task that is a waitForCondition (flat WaitForCondition op under the DAG).
+ *
+ * poll(waitForCondition from 0, +1 per poll, stops when state reaches 2 → 2) -> done(step[poll]=poll*5=10). The
+ * wait suspends and resumes the whole invocation, proving the DAG drains across the suspend/resume boundary.
+ * maxConcurrency=1. Every task succeeds → ALL_COMPLETED. Returns the canonical summary from 10-8.yaml.
+ */
+public class DagWaitForCondition extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "wfcdag",
+ d -> {
+ var poll = d.waitForCondition(
+ "poll",
+ Integer.class,
+ (deps, state, ctx) -> {
+ int next = state + 1;
+ return next >= 2
+ ? WaitForConditionResult.stopPolling(next)
+ : WaitForConditionResult.continuePolling(next);
+ },
+ WaitForConditionConfig.builder()
+ .initialState(0)
+ .build());
+ d.step("done", Integer.class, (deps, s) -> deps.get(poll).orElseThrow() * 5)
+ .reads(poll);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("poll", r.getResult("poll").orElseThrow());
+ out.put("done", r.getResult("done").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/src/main/java/dag/DagWaitResume.java b/conformance-tests/src/main/java/dag/DagWaitResume.java
new file mode 100644
index 000000000..b7fe4a2aa
--- /dev/null
+++ b/conformance-tests/src/main/java/dag/DagWaitResume.java
@@ -0,0 +1,42 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.dag.DagConfig;
+import software.amazon.lambda.durable.dag.DagResult;
+
+/**
+ * 10-4: DAG in-graph Wait task (suspend and resume).
+ *
+ * start -> pause(Wait 5s) -> finish. pause suspends the whole invocation until the wait elapses, then resumes
+ * in a fresh invocation. finish returns "resumed", proving the DAG ran across the suspend/resume boundary. Returns the
+ * canonical summary from 10-4.yaml.
+ */
+public class DagWaitResume extends DurableHandler> {
+
+ @Override
+ public Map handleRequest(Object input, DurableContext context) {
+ DagResult r = dag(
+ "waitresume",
+ d -> {
+ var start = d.step("start", String.class, (deps, s) -> "started");
+ var pause = d.wait("pause", Duration.ofSeconds(5)).after(start);
+ d.step("finish", String.class, (deps, s) -> "resumed").after(pause);
+ },
+ DagConfig.builder().maxConcurrency(1).build());
+
+ Map out = new LinkedHashMap<>();
+ out.put("reason", r.completionReason().name());
+ out.put("statuses", DagSummary.statuses(r));
+ out.put("counts", DagSummary.counts(r));
+ out.put("marker", r.getResult("finish").orElseThrow());
+ return out;
+ }
+}
diff --git a/conformance-tests/template_dag.yaml b/conformance-tests/template_dag.yaml
new file mode 100644
index 000000000..04ba292f7
--- /dev/null
+++ b/conformance-tests/template_dag.yaml
@@ -0,0 +1,388 @@
+AWSTemplateFormatVersion: '2010-09-09'
+Transform: AWS::Serverless-2016-10-31
+Description: Durable Execution Conformance Test Examples - Java (DAG)
+
+Parameters:
+ Architecture:
+ Type: String
+ Default: arm64
+ Description: Lambda Function Architecture
+ AllowedValues:
+ - x86_64
+ - arm64
+ JavaVersion:
+ Type: String
+ Default: 'java21'
+ Description: Java runtime version
+
+Globals:
+ Function:
+ Timeout: 60
+ MemorySize: 512
+ Runtime:
+ Ref: JavaVersion
+ Architectures:
+ - Ref: Architecture
+
+Resources:
+ DurableFunctionRole:
+ Type: AWS::IAM::Role
+ Properties:
+ AssumeRolePolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: Allow
+ Principal:
+ Service: lambda.amazonaws.com
+ Action: sts:AssumeRole
+ ManagedPolicyArns:
+ - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+ Policies:
+ - PolicyName: DurableExecutionPolicy
+ PolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: Allow
+ Action:
+ - lambda:CheckpointDurableExecution
+ - lambda:GetDurableExecutionState
+ - lambda:InvokeFunction
+ Resource: '*'
+
+ DagDiamond:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-1"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagDiamond
+ Description: DAG diamond fan-out/fan-in (all tasks complete)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagCompensation:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-2"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagCompensation
+ Description: DAG trigger-rule compensation (COMPLETED_WITH_FAILURES)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagRunIf:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-3"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagRunIf
+ Description: DAG per-task conditional execution (runIf)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagWaitResume:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-4"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagWaitResume
+ Description: DAG in-graph Wait task (suspend and resume)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagChild:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-5"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagChild
+ Description: DAG task that is a runInChildContext (flat child container)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagMap:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-6"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagMap
+ Description: DAG task that is a map over a fixed item list (flat map container)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagParallel:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-7"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagParallel
+ Description: DAG task that is a parallel of two named branches (flat parallel container)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagWaitForCondition:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-8"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagWaitForCondition
+ Description: DAG task that is a waitForCondition (flat WaitForCondition op)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagNested:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-9"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagNested
+ Description: DAG task that is itself a nested DAG / sub-dag (flat nested Dag container)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ # Target Functions
+
+ TargetEcho:
+ Type: AWS::Serverless::Function
+ Properties:
+ CodeUri: .
+ Handler: invoke.TargetEcho
+ Description: Echo target function — returns whatever it receives
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagInvoke:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-10"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagInvoke
+ Description: DAG task that is an invoke of another Lambda (flat invoke op)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+ Environment:
+ Variables:
+ TARGET_FUNCTION_NAME:
+ Fn::Sub: "${TargetEcho.Arn}:$LATEST"
+
+ DagCallback:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-11"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagCallback
+ Description: DAG task that is a callback / wait-for-callback (flat callback container)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagRunIfAbort:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-12"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagRunIfAbort
+ Description: DAG runIf abort path — throwing predicate aborts the DAG with a typed error (execution FAILS)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagConcurrentOverlap:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-13"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagConcurrentOverlap
+ Description: DAG real overlap inside one invocation (maxConcurrency unset; peak concurrency instrumented)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+ DagIdStability:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-20"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagIdStability
+ Description: DAG task ids are name-based, verified by invoking twice with completion order swapped
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagConcurrentSuspend:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-14"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagConcurrentSuspend
+ Description: DAG inverted readiness across a suspend (two in-flight waits; maxConcurrency unset)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagLargePayload:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-15"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagLargePayload
+ Description: DAG large aggregate result offloaded and replayed across a suspend (digest round-trips intact)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagNestedLargePayload:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-17"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagNestedLargePayload
+ Description: Nested DAG whose inner aggregate is offloaded, then replayed across a suspend (inner per-task detail survives offload of both containers)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagRetry:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-16"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagRetry
+ Description: DAG per-task retry — a flaky task retries and succeeds, its result flows downstream (ALL_COMPLETED)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ DagCompensate:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-18"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagCompensate
+ Description: DAG compensation dependency read on a failed upstream is absent, not present (deps-nullability)
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+ DagRulesEngine:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-19"]
+ Properties:
+ CodeUri: .
+ Handler: dag.DagRulesEngine
+ Description: DAG custom result-based completion short-circuits on a rejected verdict
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
\ No newline at end of file
diff --git a/docs/DAG_STATUS_JAVA.md b/docs/DAG_STATUS_JAVA.md
new file mode 100644
index 000000000..e167b72e6
--- /dev/null
+++ b/docs/DAG_STATUS_JAVA.md
@@ -0,0 +1,31 @@
+# DAG Implementation Status - Java
+
+**Branch:** `feature/dag-support` (local only; do not push).
+
+**Stability:** EXPERIMENTAL (`@Experimental` on public DAG symbols).
+
+## Extension SPI migration
+
+- Public entry points are static `DagOperations.dag(...)` and `dagAsync(...)` methods.
+- `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`.
+- Map, parallel, and wait-for-condition expose reserved-parent overloads so extension schedulers can compose them
+ without allocating a second container operation.
+- `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..cbfa7565c
--- /dev/null
+++ b/docs/core/dag.md
@@ -0,0 +1,200 @@
+# DAG (`DagOperations.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.annotations.Experimental` and carries a Javadoc `@apiNote`. Do not depend on it in
+> production until it is promoted to stable.
+
+`DagOperations.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.DagOperations.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 `DagOperations` 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/src/main/java/software/amazon/lambda/durable/examples/dag/DagCompensationExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagCompensationExample.java
new file mode 100644
index 000000000..90b295471
--- /dev/null
+++ b/examples/src/main/java/software/amazon/lambda/durable/examples/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.dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.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/dag/DagDiamondExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagDiamondExample.java
new file mode 100644
index 000000000..3fc110360
--- /dev/null
+++ b/examples/src/main/java/software/amazon/lambda/durable/examples/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.dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.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/dag/DagRunIfExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagRunIfExample.java
new file mode 100644
index 000000000..2858ed122
--- /dev/null
+++ b/examples/src/main/java/software/amazon/lambda/durable/examples/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.dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.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/dag/DagWaitResumeExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagWaitResumeExample.java
new file mode 100644
index 000000000..934d516c9
--- /dev/null
+++ b/examples/src/main/java/software/amazon/lambda/durable/examples/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.dag;
+
+import static software.amazon.lambda.durable.dag.DagOperations.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/CloudBasedIntegrationTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedIntegrationTest.java
index 0f55923ea..8a64ad1c1 100644
--- a/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedIntegrationTest.java
+++ b/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedIntegrationTest.java
@@ -866,4 +866,69 @@ void testOtelExample() {
assertNotNull(runner.getOperation("create-greeting"));
assertNotNull(runner.getOperation("transform"));
}
+
+ // ── DAG cloud integration tests (feature/dag-support) ──────────────────────
+ // These exercise the DAG primitive end-to-end against the real backend. A SUCCEEDED execution with the
+ // correct terminal DagResult is definitive proof that the DAG container child-context op and every task op
+ // checkpointed successfully — i.e. NO "Invalid parent operation id" (the Go bug) and NO op-id-length
+ // ValidationException (the Python bug), both of which surface as execution FAILURES.
+
+ @Test
+ void testDagDiamondExample() {
+ var runner =
+ CloudDurableTestRunner.create(arn("dag-diamond-example"), String.class, String.class, lambdaClient);
+ var result = runner.run("go");
+
+ assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
+ assertEquals("ABAC", result.getResult());
+
+ // DAG container child-context op is materialized, and task ops checkpoint under it.
+ assertNotNull(runner.getOperation("etl"));
+ assertNotNull(runner.getOperation("a"));
+ assertNotNull(runner.getOperation("dd"));
+ }
+
+ @Test
+ void testDagCompensationExample() {
+ var runner = CloudDurableTestRunner.create(
+ arn("dag-compensation-example"), String.class, String.class, lambdaClient);
+ var result = runner.run("go");
+
+ assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
+ // charge FAILED → refund runs (ALL_FAILED), fulfill SKIPPED (ALL_SUCCESS over failure), audit runs (ALL_DONE).
+ assertEquals("COMPLETED_WITH_FAILURES|FAILED|SUCCEEDED|SKIPPED|SUCCEEDED", result.getResult());
+
+ assertNotNull(runner.getOperation("saga"));
+ assertNotNull(runner.getOperation("charge"));
+ assertNotNull(runner.getOperation("refund"));
+ assertNotNull(runner.getOperation("audit"));
+ }
+
+ @Test
+ void testDagRunIfExample() {
+ var runner = CloudDurableTestRunner.create(arn("dag-run-if-example"), String.class, String.class, lambdaClient);
+ var result = runner.run("go");
+
+ assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
+ // gate=0 → maybe SKIPPED (runIf false) → after SKIPPED (ALL_SUCCESS over skipped upstream).
+ assertEquals("SKIPPED|SKIPPED", result.getResult());
+
+ assertNotNull(runner.getOperation("cond"));
+ assertNotNull(runner.getOperation("gate"));
+ }
+
+ @Test
+ void testDagWaitResumeExample() {
+ var runner =
+ CloudDurableTestRunner.create(arn("dag-wait-resume-example"), String.class, String.class, lambdaClient);
+ var result = runner.run("go");
+
+ assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
+ // In-DAG wait forces a real suspend/replay; name-based IDs keep the join deterministic.
+ assertEquals("ABAC", result.getResult());
+
+ assertNotNull(runner.getOperation("diamond_wait"));
+ assertNotNull(runner.getOperation("w"));
+ assertNotNull(runner.getOperation("join"));
+ }
}
diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagConformanceTest.java b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagConformanceTest.java
new file mode 100644
index 000000000..a169a70a5
--- /dev/null
+++ b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagConformanceTest.java
@@ -0,0 +1,1029 @@
+// 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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static software.amazon.lambda.durable.dag.DagOperations.dag;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+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.DagCyclicDependencyException;
+import software.amazon.lambda.durable.dag.DagDuplicateTaskException;
+import software.amazon.lambda.durable.dag.DagException;
+import software.amazon.lambda.durable.dag.DagInvalidDependencyException;
+import software.amazon.lambda.durable.dag.DagInvalidTaskNameException;
+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;
+import software.amazon.lambda.durable.dag.TriggerRule;
+import software.amazon.lambda.durable.dag.internal.DagContextImpl;
+import software.amazon.lambda.durable.dag.internal.DagExecutor;
+import software.amazon.lambda.durable.dag.internal.DagValidator;
+import software.amazon.lambda.durable.dag.internal.TaskHandleImpl;
+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;
+
+/**
+ * Cross-language DAG conformance suite (Java). Implements the scenarios from {@code docs/DAG_CONFORMANCE.md} in the
+ * TypeScript SDK repo against the shipped {@code feature/dag-support} Java API, asserts each actual outcome equals the
+ * catalog's expected semantic outcome, and emits one key-sorted normalized JSON to
+ * {@code target/dag-conformance/java.json} by default (schema per catalog Part B).
+ *
+ * DAG-18 (custom result-based completion) originally shipped TS + Go only; Java added
+ * {@code DagCompletionConfig.custom(...)} after v1 (see {@code DAG_SPEC_CROSS_LANGUAGE.md} §4.2), so Java now emits it
+ * too (java.json carries 19 records).
+ */
+@TestMethodOrder(MethodOrderer.MethodName.class)
+class DagConformanceTest {
+
+ private static final Path OUT =
+ Path.of(System.getProperty("dag.conformance.output", "target/dag-conformance/java.json"));
+
+ /** Accumulated normalized records, keyed by scenario id (TreeMap → lexicographic key sort). */
+ private static final Map RECORDS = new TreeMap<>();
+
+ private static final String STEP_ERROR = "StepError";
+
+ // ── DAG-1 ─────────────────────────────────────────────────────────────────
+ @Test
+ void dag1_diamond() {
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag1", d -> {
+ var fetch = d.step("fetch", Integer.class, (deps, s) -> 10);
+ var ta = d.step(
+ "ta",
+ Integer.class,
+ (deps, s) -> deps.get(fetch).orElseThrow() + 1)
+ .reads(fetch);
+ var tb = d.step(
+ "tb",
+ Integer.class,
+ (deps, s) -> deps.get(fetch).orElseThrow() * 2)
+ .reads(fetch);
+ d.step(
+ "merge",
+ Integer.class,
+ (deps, s) -> deps.get(ta).orElseThrow()
+ + deps.get(tb).orElseThrow())
+ .reads(ta, tb);
+ });
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("merge").orElseThrow());
+ assertEquals(31, r.getResult("merge").orElseThrow());
+ assertEquals(DagCompletionReason.ALL_COMPLETED, r.completionReason());
+ assertCounts(r, 4, 0, 0, 4);
+ RECORDS.put("DAG-1", record("DAG-1", r, List.of("fetch", "ta", "tb", "merge")));
+ }
+
+ // ── DAG-2 ─────────────────────────────────────────────────────────────────
+ @Test
+ void dag2_compensationChargeFails() {
+ var noRetry = StepConfig.builder()
+ .retryStrategy(RetryStrategies.Presets.NO_RETRY)
+ .build();
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag2", d -> {
+ var charge = d.step(
+ "charge",
+ String.class,
+ (deps, s) -> {
+ throw new RuntimeException("charge failed");
+ },
+ noRetry);
+ d.step("fulfill", String.class, (deps, s) -> "fulfilled").after(charge);
+ d.step("refund", String.class, (deps, s) -> "refunded")
+ .after(charge)
+ .triggerRule(TriggerRule.ALL_FAILED);
+ d.step("audit", String.class, (deps, s) -> "audited")
+ .after(charge)
+ .triggerRule(TriggerRule.ALL_DONE);
+ });
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(TaskStatus.FAILED, r.getStatus("charge").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("fulfill").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("refund").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("audit").orElseThrow());
+ assertEquals(DagCompletionReason.COMPLETED_WITH_FAILURES, r.completionReason());
+ assertCounts(r, 2, 1, 1, 4);
+ RECORDS.put("DAG-2", record("DAG-2", r, List.of("charge", "fulfill", "refund", "audit")));
+ }
+
+ // ── DAG-3 ─────────────────────────────────────────────────────────────────
+ @Test
+ void dag3_compensationChargeSucceeds() {
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag3", d -> {
+ var charge = d.step("charge", String.class, (deps, s) -> "charged");
+ d.step("fulfill", String.class, (deps, s) -> "fulfilled").after(charge);
+ d.step("refund", String.class, (deps, s) -> "refunded")
+ .after(charge)
+ .triggerRule(TriggerRule.ALL_FAILED);
+ d.step("audit", String.class, (deps, s) -> "audited")
+ .after(charge)
+ .triggerRule(TriggerRule.ALL_DONE);
+ });
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("charge").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("fulfill").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("refund").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("audit").orElseThrow());
+ assertEquals(DagCompletionReason.ALL_COMPLETED, r.completionReason());
+ assertCounts(r, 3, 0, 1, 4);
+ RECORDS.put("DAG-3", record("DAG-3", r, List.of("charge", "fulfill", "refund", "audit")));
+ }
+
+ // ── DAG-4 ─────────────────────────────────────────────────────────────────
+ @Test
+ void dag4_runIfBranching() {
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag4", d -> {
+ var classify = d.step("classify", String.class, (deps, s) -> "review");
+ d.step("publish", String.class, (deps, s) -> "published")
+ .reads(classify)
+ .runIf(deps -> "publish".equals(deps.get(classify).orElse(null)));
+ d.step("review", String.class, (deps, s) -> "reviewed")
+ .reads(classify)
+ .runIf(deps -> "review".equals(deps.get(classify).orElse(null)));
+ d.step("block", String.class, (deps, s) -> "blocked")
+ .reads(classify)
+ .runIf(deps -> "block".equals(deps.get(classify).orElse(null)));
+ });
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("review").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("publish").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("block").orElseThrow());
+ assertEquals(DagCompletionReason.ALL_COMPLETED, r.completionReason());
+ assertCounts(r, 2, 0, 2, 4);
+ RECORDS.put("DAG-4", record("DAG-4", r, List.of("classify", "publish", "review", "block")));
+ }
+
+ // ── DAG-5 ─────────────────────────────────────────────────────────────────
+ @Test
+ void dag5_triggerMatrixEmptyUpstream() {
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag5", d -> {
+ d.step("r_all_success", String.class, (deps, s) -> "ok").triggerRule(TriggerRule.ALL_SUCCESS);
+ d.step("r_all_failed", String.class, (deps, s) -> "ok").triggerRule(TriggerRule.ALL_FAILED);
+ d.step("r_all_done", String.class, (deps, s) -> "ok").triggerRule(TriggerRule.ALL_DONE);
+ d.step("r_one_success", String.class, (deps, s) -> "ok").triggerRule(TriggerRule.ANY_SUCCESS);
+ d.step("r_one_failed", String.class, (deps, s) -> "ok").triggerRule(TriggerRule.ANY_FAILED);
+ d.step("r_none_failed", String.class, (deps, s) -> "ok").triggerRule(TriggerRule.NONE_FAILED);
+ });
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("r_all_success").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("r_all_failed").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("r_all_done").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("r_one_success").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("r_one_failed").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("r_none_failed").orElseThrow());
+ assertEquals(DagCompletionReason.ALL_COMPLETED, r.completionReason());
+ assertCounts(r, 3, 0, 3, 6);
+ RECORDS.put(
+ "DAG-5",
+ record(
+ "DAG-5",
+ r,
+ List.of(
+ "r_all_success",
+ "r_all_failed",
+ "r_all_done",
+ "r_one_success",
+ "r_one_failed",
+ "r_none_failed")));
+ }
+
+ // ── DAG-6 ─────────────────────────────────────────────────────────────────
+ @Test
+ void dag6_triggerMatrixMixed() {
+ var noRetry = StepConfig.builder()
+ .retryStrategy(RetryStrategies.Presets.NO_RETRY)
+ .build();
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag6", d -> {
+ var upOk = d.step("up_ok", String.class, (deps, s) -> "ok");
+ var upFail = d.step(
+ "up_fail",
+ String.class,
+ (deps, s) -> {
+ throw new RuntimeException("boom");
+ },
+ noRetry);
+ d.step("c_all_success", String.class, (deps, s) -> "c")
+ .after(upOk, upFail)
+ .triggerRule(TriggerRule.ALL_SUCCESS);
+ d.step("c_all_failed", String.class, (deps, s) -> "c")
+ .after(upOk, upFail)
+ .triggerRule(TriggerRule.ALL_FAILED);
+ d.step("c_all_done", String.class, (deps, s) -> "c")
+ .after(upOk, upFail)
+ .triggerRule(TriggerRule.ALL_DONE);
+ d.step("c_one_success", String.class, (deps, s) -> "c")
+ .after(upOk, upFail)
+ .triggerRule(TriggerRule.ANY_SUCCESS);
+ d.step("c_one_failed", String.class, (deps, s) -> "c")
+ .after(upOk, upFail)
+ .triggerRule(TriggerRule.ANY_FAILED);
+ d.step("c_none_failed", String.class, (deps, s) -> "c")
+ .after(upOk, upFail)
+ .triggerRule(TriggerRule.NONE_FAILED);
+ });
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("up_ok").orElseThrow());
+ assertEquals(TaskStatus.FAILED, r.getStatus("up_fail").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("c_all_success").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("c_all_failed").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("c_all_done").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("c_one_success").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("c_one_failed").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("c_none_failed").orElseThrow());
+ assertEquals(DagCompletionReason.COMPLETED_WITH_FAILURES, r.completionReason());
+ assertCounts(r, 4, 1, 3, 8);
+ RECORDS.put(
+ "DAG-6",
+ record(
+ "DAG-6",
+ r,
+ List.of(
+ "up_ok",
+ "up_fail",
+ "c_all_success",
+ "c_all_failed",
+ "c_all_done",
+ "c_one_success",
+ "c_one_failed",
+ "c_none_failed")));
+ }
+
+ // ── DAG-7 ─────────────────────────────────────────────────────────────────
+ @Test
+ void dag7_triggerMatrixAllFailed() {
+ var noRetry = StepConfig.builder()
+ .retryStrategy(RetryStrategies.Presets.NO_RETRY)
+ .build();
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag7", d -> {
+ var u1 = d.step(
+ "u1",
+ String.class,
+ (deps, s) -> {
+ throw new RuntimeException("boom");
+ },
+ noRetry);
+ var u2 = d.step(
+ "u2",
+ String.class,
+ (deps, s) -> {
+ throw new RuntimeException("boom");
+ },
+ noRetry);
+ d.step("k_all_success", String.class, (deps, s) -> "k")
+ .after(u1, u2)
+ .triggerRule(TriggerRule.ALL_SUCCESS);
+ d.step("k_all_failed", String.class, (deps, s) -> "k")
+ .after(u1, u2)
+ .triggerRule(TriggerRule.ALL_FAILED);
+ d.step("k_all_done", String.class, (deps, s) -> "k")
+ .after(u1, u2)
+ .triggerRule(TriggerRule.ALL_DONE);
+ d.step("k_one_success", String.class, (deps, s) -> "k")
+ .after(u1, u2)
+ .triggerRule(TriggerRule.ANY_SUCCESS);
+ d.step("k_one_failed", String.class, (deps, s) -> "k")
+ .after(u1, u2)
+ .triggerRule(TriggerRule.ANY_FAILED);
+ d.step("k_none_failed", String.class, (deps, s) -> "k")
+ .after(u1, u2)
+ .triggerRule(TriggerRule.NONE_FAILED);
+ });
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(TaskStatus.FAILED, r.getStatus("u1").orElseThrow());
+ assertEquals(TaskStatus.FAILED, r.getStatus("u2").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("k_all_success").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("k_all_failed").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("k_all_done").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("k_one_success").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("k_one_failed").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("k_none_failed").orElseThrow());
+ assertEquals(DagCompletionReason.COMPLETED_WITH_FAILURES, r.completionReason());
+ assertCounts(r, 3, 2, 3, 8);
+ RECORDS.put(
+ "DAG-7",
+ record(
+ "DAG-7",
+ r,
+ List.of(
+ "u1",
+ "u2",
+ "k_all_success",
+ "k_all_failed",
+ "k_all_done",
+ "k_one_success",
+ "k_one_failed",
+ "k_none_failed")));
+ }
+
+ // ── DAG-8 ─────────────────────────────────────────────────────────────────
+ @Test
+ void dag8_skipCascade() {
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag8", d -> {
+ var seed = d.step("seed", Integer.class, (deps, s) -> 1);
+ var gate = d.step("gate", String.class, (deps, s) -> "gate")
+ .reads(seed)
+ .runIf(deps -> ((Integer) deps.get(seed).orElseThrow()) > 100);
+ var d1 = d.step("d1", String.class, (deps, s) -> "d1").after(gate);
+ d.step("d2", String.class, (deps, s) -> "d2").after(d1);
+ d.step("sink", String.class, (deps, s) -> "sink").after(gate).triggerRule(TriggerRule.ALL_DONE);
+ });
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("seed").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("gate").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("d1").orElseThrow());
+ assertEquals(TaskStatus.SKIPPED, r.getStatus("d2").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("sink").orElseThrow());
+ assertEquals(DagCompletionReason.ALL_COMPLETED, r.completionReason());
+ assertCounts(r, 2, 0, 3, 5);
+ RECORDS.put("DAG-8", record("DAG-8", r, List.of("seed", "gate", "d1", "d2", "sink")));
+ }
+
+ // ── DAG-9 ─────────────────────────────────────────────────────────────────
+ @Test
+ void dag9_nestedDag() {
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag9", d -> {
+ var a = d.step("a", Integer.class, (deps, s) -> 2);
+ var inner = d.dag("inner", innerCtx -> {
+ var x = innerCtx.step("x", Integer.class, (deps, s) -> 3);
+ innerCtx.step(
+ "y",
+ Integer.class,
+ (deps, s) -> deps.get(x).orElseThrow() * 10)
+ .reads(x);
+ })
+ .after(a);
+ d.step("consume", Integer.class, (deps, s) -> {
+ DagResult innerResult = deps.get(inner).orElseThrow();
+ return (Integer) innerResult.getResult("y").orElseThrow() + 5;
+ })
+ .reads(inner);
+ });
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(2, r.getResult("a").orElseThrow());
+ assertEquals(35, r.getResult("consume").orElseThrow());
+ DagResult inner = (DagResult) r.getResult("inner").orElseThrow();
+ assertEquals(DagCompletionReason.ALL_COMPLETED, inner.completionReason());
+ assertEquals(3, inner.getResult("x").orElseThrow());
+ assertEquals(30, inner.getResult("y").orElseThrow());
+ assertCounts(inner, 2, 0, 0, 2);
+ // Scope isolation: inner task names are invisible in the outer result.
+ assertTrue(r.getStatus("x").isEmpty());
+ assertTrue(r.getStatus("y").isEmpty());
+ assertEquals(DagCompletionReason.ALL_COMPLETED, r.completionReason());
+ assertCounts(r, 3, 0, 0, 3);
+ RECORDS.put("DAG-9", record("DAG-9", r, List.of("a", "inner", "consume")));
+ }
+
+ // ── DAG-10 ────────────────────────────────────────────────────────────────
+ @Test
+ void dag10_emptyDag() {
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag10", d -> {});
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(DagCompletionReason.ALL_COMPLETED, r.completionReason());
+ assertCounts(r, 0, 0, 0, 0);
+ assertTrue(r.results().isEmpty());
+ RECORDS.put("DAG-10", record("DAG-10", r, List.of()));
+ }
+
+ // ── DAG-11..15 (validation) ─────────────────────────────────────────────
+ // DagOperations registers and validates the graph eagerly at the dag() call site before reserving the extension
+ // context container. We drive that exact path to observe the typed DagException raised by validation.
+
+ @Test
+ void dag11_cycle() {
+ assertValidation("DAG-11", DagCyclicDependencyException.class, "DagCyclicDependencyError", () -> {
+ var d = new DagContextImpl();
+ var p = d.step("p", String.class, (deps, s) -> "p");
+ var q = d.step("q", String.class, (deps, s) -> "q");
+ p.after(q);
+ q.after(p);
+ return d.tasks();
+ });
+ }
+
+ @Test
+ void dag12_duplicate() {
+ assertValidation("DAG-12", DagDuplicateTaskException.class, "DagDuplicateTaskError", () -> {
+ var d = new DagContextImpl();
+ d.step("dup", String.class, (deps, s) -> "1");
+ d.step("dup", String.class, (deps, s) -> "2");
+ return d.tasks();
+ });
+ }
+
+ @Test
+ void dag13_invalidNameDash() {
+ assertValidation("DAG-13", DagInvalidTaskNameException.class, "DagInvalidTaskNameError", () -> {
+ var d = new DagContextImpl();
+ d.step("fetch-data", String.class, (deps, s) -> "x");
+ return d.tasks();
+ });
+ }
+
+ @Test
+ void dag14_invalidNameReservedToken() {
+ assertValidation("DAG-14", DagInvalidTaskNameException.class, "DagInvalidTaskNameError", () -> {
+ var d = new DagContextImpl();
+ d.step("DAG_NODE_T_root", String.class, (deps, s) -> "x");
+ return d.tasks();
+ });
+ }
+
+ @Test
+ void dag15_foreignDependency() {
+ assertValidation("DAG-15", DagInvalidDependencyException.class, "DagInvalidDependencyError", () -> {
+ // 'foreign' is registered in a DIFFERENT DAG scope, so it is not in this scope's registry.
+ var sibling = new DagContextImpl();
+ TaskHandle foreign = sibling.step("foreign", String.class, (deps, s) -> "f");
+ var d = new DagContextImpl();
+ d.step("t", String.class, (deps, s) -> "t").after(foreign);
+ return d.tasks();
+ });
+ }
+
+ /**
+ * Verifies that registration-time validation errors surface with their typed identity through the end-to-end runner
+ * path. Validation runs at the {@code dag()} call site before the extension context container launches.
+ */
+ @Test
+ void validationErrorTypeSurfacesUnwrappedThroughRunner() {
+ var caught = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ try {
+ dag("cyc_e2e", d -> {
+ var p = d.step("p", String.class, (deps, s) -> "p");
+ var q = d.step("q", String.class, (deps, s) -> "q");
+ p.after(q);
+ q.after(p);
+ });
+ return "no-error";
+ } catch (Throwable t) {
+ caught.set(t);
+ return "error";
+ }
+ });
+ runner.runUntilComplete("go");
+ Throwable t = caught.get();
+ assertNotNull(t, "a failure must propagate for an invalid graph");
+ assertTrue(
+ t instanceof DagCyclicDependencyException,
+ "runner must surface the typed DagCyclicDependencyException unwrapped, got: " + t.getClass());
+ }
+
+ /**
+ * A second validation-error type ({@link DagDuplicateTaskException}) also surfaces unwrapped through the runner,
+ * confirming the fix is not specific to the cyclic case.
+ */
+ @Test
+ void duplicateTaskValidationErrorSurfacesUnwrappedThroughRunner() {
+ var caught = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ try {
+ dag("dup_e2e", d -> {
+ d.step("dup", String.class, (deps, s) -> "1");
+ d.step("dup", String.class, (deps, s) -> "2");
+ });
+ return "no-error";
+ } catch (Throwable t) {
+ caught.set(t);
+ return "error";
+ }
+ });
+ runner.runUntilComplete("go");
+ Throwable t = caught.get();
+ assertNotNull(t, "a failure must propagate for a duplicate-name graph");
+ assertTrue(
+ t instanceof DagDuplicateTaskException,
+ "runner must surface the typed DagDuplicateTaskException unwrapped, got: " + t.getClass());
+ }
+
+ // ── DAG-16 ────────────────────────────────────────────────────────────────
+ @Test
+ void dag16_minSuccessful() {
+ var config = DagConfig.builder()
+ .maxConcurrency(1)
+ .completionConfig(DagCompletionConfig.minSuccessful(3))
+ .build();
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag(
+ "dag16",
+ d -> {
+ var s1 = d.step("s1", Integer.class, (deps, s) -> 1);
+ var s2 = d.step("s2", Integer.class, (deps, s) -> 2).after(s1);
+ var s3 = d.step("s3", Integer.class, (deps, s) -> 3).after(s2);
+ var s4 = d.step("s4", Integer.class, (deps, s) -> 4).after(s3);
+ d.step("s5", Integer.class, (deps, s) -> 5).after(s4);
+ },
+ config);
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("s1").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("s2").orElseThrow());
+ assertEquals(TaskStatus.SUCCEEDED, r.getStatus("s3").orElseThrow());
+ // s4, s5 never started → absent from the results map.
+ assertTrue(r.getStatus("s4").isEmpty(), "s4 must be absent (never started)");
+ assertTrue(r.getStatus("s5").isEmpty(), "s5 must be absent (never started)");
+ assertEquals(DagCompletionReason.MIN_SUCCESSFUL_REACHED, r.completionReason());
+ assertEquals(3, r.successCount());
+ assertEquals(0, r.failureCount());
+ assertEquals(0, r.skippedCount());
+ // Per spec §2.8: totalCount == number of REGISTERED tasks (5), fixed and independent of early completion.
+ // s4, s5 never started and stay absent from the results map (§2.9/§9.6); getStatus disambiguates.
+ assertEquals(5, r.totalCount());
+ RECORDS.put("DAG-16", record("DAG-16", r, List.of("s1", "s2", "s3", "s4", "s5")));
+ }
+
+ // ── DAG-17 ────────────────────────────────────────────────────────────────
+ @Test
+ void dag17_toleratedFailureCount() {
+ var noRetry = StepConfig.builder()
+ .retryStrategy(RetryStrategies.Presets.NO_RETRY)
+ .build();
+ var config = DagConfig.builder()
+ .maxConcurrency(1)
+ .completionConfig(DagCompletionConfig.toleratedFailureCount(1))
+ .build();
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag(
+ "dag17",
+ d -> {
+ var t1 = d.step(
+ "t1",
+ String.class,
+ (deps, s) -> {
+ throw new RuntimeException("boom");
+ },
+ noRetry);
+ var t2 = d.step(
+ "t2",
+ String.class,
+ (deps, s) -> {
+ throw new RuntimeException("boom");
+ },
+ noRetry)
+ .after(t1)
+ .triggerRule(TriggerRule.ALL_DONE);
+ var t3 = d.step(
+ "t3",
+ String.class,
+ (deps, s) -> {
+ throw new RuntimeException("boom");
+ },
+ noRetry)
+ .after(t2)
+ .triggerRule(TriggerRule.ALL_DONE);
+ d.step(
+ "t4",
+ String.class,
+ (deps, s) -> {
+ throw new RuntimeException("boom");
+ },
+ noRetry)
+ .after(t3)
+ .triggerRule(TriggerRule.ALL_DONE);
+ },
+ config);
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(TaskStatus.FAILED, r.getStatus("t1").orElseThrow());
+ assertEquals(TaskStatus.FAILED, r.getStatus("t2").orElseThrow());
+ assertTrue(r.getStatus("t3").isEmpty(), "t3 must be absent (never started)");
+ assertTrue(r.getStatus("t4").isEmpty(), "t4 must be absent (never started)");
+ assertEquals(DagCompletionReason.FAILURE_TOLERANCE_EXCEEDED, r.completionReason());
+ assertEquals(0, r.successCount());
+ assertEquals(2, r.failureCount());
+ assertEquals(0, r.skippedCount());
+ // Per spec §2.8: totalCount == number of REGISTERED tasks (4), fixed and independent of early completion.
+ // t3, t4 never started and stay absent from the results map (§2.9/§9.6); getStatus disambiguates.
+ assertEquals(4, r.totalCount());
+ RECORDS.put("DAG-17", record("DAG-17", r, List.of("t1", "t2", "t3", "t4")));
+ }
+
+ // ── DAG-18 ────────────────────────────────────────────────────────────────
+ @Test
+ void dag18_customCompletion() {
+ 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(((Map, ?>) item.result().get()).get("verdict")));
+ 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 ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag(
+ "dag18",
+ d -> {
+ var r1 = d.step("r1", Map.class, (deps, s) -> Map.of("verdict", "ACCEPT"));
+ var r2 = d.step("r2", Map.class, (deps, s) -> Map.of("verdict", "REJECT"))
+ .reads(r1);
+ d.step("r3", Map.class, (deps, s) -> Map.of("verdict", "ACCEPT"))
+ .reads(r2);
+ },
+ config);
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(Map.of("verdict", "ACCEPT"), r.getResult("r1").orElseThrow());
+ assertEquals(Map.of("verdict", "REJECT"), r.getResult("r2").orElseThrow());
+ assertTrue(r.getStatus("r3").isEmpty(), "r3 must be absent (never started)");
+ assertEquals(DagCompletionReason.CUSTOM_COMPLETION_FAILED, r.completionReason());
+ assertEquals(0, r.failureCount());
+ assertCounts(r, 2, 0, 0, 3);
+ assertThrows(software.amazon.lambda.durable.dag.DagExecutionException.class, r::throwIfError);
+ RECORDS.put("DAG-18", record("DAG-18", r, List.of("r1", "r2", "r3")));
+ }
+
+ // ── DAG-19 ────────────────────────────────────────────────────────────────
+ @Test
+ void dag19_orderIndependence() {
+ // run1: register b before c; run2: register c before b (perturbed completion/registration order).
+ Object rec1 = runDiamond(true);
+ Object rec2 = runDiamond(false);
+ // The two emitted records MUST be byte-identical regardless of branch order (name-based IDs).
+ assertEquals(toJson(rec1, ""), toJson(rec2, ""), "DAG-19 record must be order-independent");
+ RECORDS.put("DAG-19", rec1);
+ }
+
+ private Object runDiamond(boolean bFirst) {
+ var ref = new AtomicReference();
+ var runner = LocalDurableTestRunner.create(String.class, (in, ctx) -> {
+ DagResult r = dag("dag19", d -> {
+ var root = d.step("root", Integer.class, (deps, s) -> 100);
+ TaskHandle b;
+ TaskHandle c;
+ if (bFirst) {
+ b = d.step("b", Integer.class, (deps, s) -> deps.get(root).orElseThrow() + 1)
+ .reads(root);
+ c = d.step("c", Integer.class, (deps, s) -> deps.get(root).orElseThrow() + 2)
+ .reads(root);
+ } else {
+ c = d.step("c", Integer.class, (deps, s) -> deps.get(root).orElseThrow() + 2)
+ .reads(root);
+ b = d.step("b", Integer.class, (deps, s) -> deps.get(root).orElseThrow() + 1)
+ .reads(root);
+ }
+ d.step(
+ "merge",
+ Integer.class,
+ (deps, s) ->
+ deps.get(b).orElseThrow() + deps.get(c).orElseThrow())
+ .reads(b, c);
+ });
+ ref.set(r);
+ return "ok";
+ });
+ assertSucceeded(runner);
+ DagResult r = ref.get();
+ assertEquals(100, r.getResult("root").orElseThrow());
+ assertEquals(101, r.getResult("b").orElseThrow());
+ assertEquals(102, r.getResult("c").orElseThrow());
+ assertEquals(203, r.getResult("merge").orElseThrow());
+ assertEquals(DagCompletionReason.ALL_COMPLETED, r.completionReason());
+ assertCounts(r, 4, 0, 0, 4);
+ return record("DAG-19", r, List.of("root", "b", "c", "merge"));
+ }
+
+ // ── emission ──────────────────────────────────────────────────────────────
+ @AfterAll
+ static void writeConformanceJson() throws IOException {
+ // 19 scenarios for Java: DAG-18 now applies to all four languages.
+ assertEquals(19, RECORDS.size(), "Java must emit exactly 19 conformance records");
+ assertTrue(RECORDS.containsKey("DAG-1") && RECORDS.containsKey("DAG-19"));
+ assertTrue(RECORDS.containsKey("DAG-18"), "DAG-18 now applies to Java too");
+ String json = toJson(RECORDS, "") + "\n";
+ Files.createDirectories(OUT.getParent());
+ Files.writeString(OUT, json, StandardCharsets.UTF_8);
+ }
+
+ // ── helpers ─────────────────────────────────────────────────────────────
+
+ private static void assertSucceeded(LocalDurableTestRunner runner) {
+ var result = runner.runUntilComplete("go");
+ assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
+ }
+
+ private static void assertCounts(DagResult r, int success, int failure, int skipped, int total) {
+ assertEquals(success, r.successCount(), "successCount");
+ assertEquals(failure, r.failureCount(), "failureCount");
+ assertEquals(skipped, r.skippedCount(), "skippedCount");
+ assertEquals(total, r.totalCount(), "totalCount");
+ }
+
+ /**
+ * Asserts the shipped {@link DagValidator} (the validation used by {@code DagOperations}) raises the expected typed
+ * {@link DagException} for a graph, and records the normalized error token.
+ */
+ private void assertValidation(
+ String scenario,
+ Class extends DagException> expected,
+ String normalizedToken,
+ Supplier>> graph) {
+ List> tasks = graph.get();
+ DagException dagEx = assertThrows(DagException.class, () -> DagValidator.validate(tasks), scenario);
+ assertTrue(
+ expected.isInstance(dagEx),
+ scenario + ": expected " + expected.getSimpleName() + " but got "
+ + dagEx.getClass().getSimpleName());
+ RECORDS.put(scenario, validationRecord(scenario, normalizedToken));
+ }
+
+ /** Builds a normalized conformance record (Part B schema) from a completed DagResult. */
+ private static Map record(String scenario, DagResult r, List registeredNames) {
+ Map rec = new TreeMap<>();
+ rec.put("scenario", scenario);
+
+ Map tasks = new TreeMap<>();
+ for (Map.Entry> e : r.results().entrySet()) {
+ TaskExecution> exec = e.getValue();
+ tasks.put(e.getKey(), taskObject(exec));
+ }
+ rec.put("tasks", tasks);
+
+ rec.put("completion_reason", r.completionReason().name());
+
+ Map counts = new TreeMap<>();
+ counts.put("success", r.successCount());
+ counts.put("failure", r.failureCount());
+ counts.put("skipped", r.skippedCount());
+ counts.put("total", r.totalCount());
+ rec.put("counts", counts);
+
+ rec.put("structural_id_checks", structuralIdChecks(registeredNames));
+ rec.put("validation_error", null);
+ return rec;
+ }
+
+ private static Map taskObject(TaskExecution> exec) {
+ Map t = new TreeMap<>();
+ TaskStatus status = exec.status();
+ t.put("status", status.name());
+ t.put(
+ "result",
+ status == TaskStatus.SUCCEEDED ? normalizeResult(exec.result().orElse(null)) : null);
+ t.put("error_type", status == TaskStatus.FAILED ? STEP_ERROR : null);
+ t.put(
+ "skip_reason",
+ status == TaskStatus.SKIPPED ? exec.skipReason().map(Enum::name).orElse(null) : null);
+ return t;
+ }
+
+ /** Normalizes a task result: nested DagResult → {completion_reason, counts}; primitives/strings pass through. */
+ private static Object normalizeResult(Object value) {
+ if (value instanceof DagResult nested) {
+ Map norm = new TreeMap<>();
+ norm.put("completion_reason", nested.completionReason().name());
+ Map counts = new TreeMap<>();
+ counts.put("success", nested.successCount());
+ counts.put("failure", nested.failureCount());
+ counts.put("skipped", nested.skippedCount());
+ counts.put("total", nested.totalCount());
+ norm.put("counts", counts);
+ return norm;
+ }
+ return value;
+ }
+
+ /**
+ * Computes and verifies the four per-language structural entity-ID invariants against Java's own ID scheme
+ * ({@code hash(contextId + "-DAG_NODE_T_" + taskName)}, see {@link OperationIdGenerator} / {@link DagExecutor}).
+ * Raw hashes are NOT compared cross-language; these are Java-local structural checks. Empty DAG -> all four
+ * vacuously true.
+ */
+ private static Map structuralIdChecks(List names) {
+ String ctxId = "conformance-ctx";
+ var gen = new OperationIdGenerator(ctxId);
+ // Precompute a pool of counter-based sibling IDs for the disjointness check.
+ Set counterIds = new HashSet<>();
+ for (int i = 0; i < names.size() + 8; i++) {
+ counterIds.add(gen.nextOperationId());
+ }
+ boolean nameBased = true;
+ boolean hasDelimiter = true;
+ boolean dashFree = true;
+ boolean disjointFromCounter = true;
+ for (String name : names) {
+ String preImage = ctxId + "-" + DagExecutor.NODE_PREFIX + name;
+ String id = gen.nextOperationId(DagExecutor.NODE_PREFIX + name);
+ // name_based: the id is derived from the task name (recomputable from the name pre-image), not a counter.
+ if (!id.equals(OperationIdGenerator.hashOperationId(preImage))) {
+ nameBased = false;
+ }
+ // has_delimiter: the pre-image contains DAG_NODE_T_ exactly once (per nesting level).
+ if (countOccurrences(preImage, DagExecutor.NODE_PREFIX) != 1) {
+ hasDelimiter = false;
+ }
+ // dash_free: the name matches the DAG charset (no dash, no reserved token).
+ if (!name.matches("^[a-zA-Z0-9_]+$") || name.contains(DagExecutor.NODE_PREFIX)) {
+ dashFree = false;
+ }
+ // disjoint_from_counter: a name-based id never collides with a sibling counter id.
+ if (counterIds.contains(id)) {
+ disjointFromCounter = false;
+ }
+ }
+ // For non-validation scenarios these MUST hold (vacuously true for the empty DAG).
+ assertTrue(nameBased, "structural: name_based");
+ assertTrue(hasDelimiter, "structural: has_delimiter");
+ assertTrue(dashFree, "structural: dash_free");
+ assertTrue(disjointFromCounter, "structural: disjoint_from_counter");
+
+ Map checks = new TreeMap<>();
+ checks.put("name_based", nameBased);
+ checks.put("has_delimiter", hasDelimiter);
+ checks.put("dash_free", dashFree);
+ checks.put("disjoint_from_counter", disjointFromCounter);
+ return checks;
+ }
+
+ private static int countOccurrences(String haystack, String needle) {
+ int count = 0;
+ int idx = 0;
+ while ((idx = haystack.indexOf(needle, idx)) != -1) {
+ count++;
+ idx += needle.length();
+ }
+ return count;
+ }
+
+ private static Map validationRecord(String scenario, String token) {
+ Map rec = new TreeMap<>();
+ rec.put("scenario", scenario);
+ rec.put("tasks", new TreeMap());
+ rec.put("completion_reason", null);
+ Map counts = new TreeMap<>();
+ counts.put("success", 0);
+ counts.put("failure", 0);
+ counts.put("skipped", 0);
+ counts.put("total", 0);
+ rec.put("counts", counts);
+ Map checks = new TreeMap<>();
+ checks.put("name_based", false);
+ checks.put("has_delimiter", false);
+ checks.put("dash_free", false);
+ checks.put("disjoint_from_counter", false);
+ rec.put("structural_id_checks", checks);
+ rec.put("validation_error", token);
+ return rec;
+ }
+
+ // ── deterministic JSON writer (UTF-8, 2-space indent, sorted keys, ints without decimals) ──
+ @SuppressWarnings("unchecked")
+ private static String toJson(Object v, String indent) {
+ if (v == null) {
+ return "null";
+ }
+ if (v instanceof String s) {
+ return quote(s);
+ }
+ if (v instanceof Boolean || v instanceof Integer || v instanceof Long) {
+ return v.toString();
+ }
+ if (v instanceof Map, ?> raw) {
+ if (raw.isEmpty()) {
+ return "{}";
+ }
+ Map m = new TreeMap<>();
+ for (Map.Entry, ?> e : raw.entrySet()) {
+ m.put((String) e.getKey(), e.getValue());
+ }
+ StringBuilder sb = new StringBuilder("{\n");
+ String ni = indent + " ";
+ int i = 0;
+ int n = m.size();
+ for (Map.Entry e : m.entrySet()) {
+ sb.append(ni).append(quote(e.getKey())).append(": ").append(toJson(e.getValue(), ni));
+ if (++i < n) {
+ sb.append(",");
+ }
+ sb.append("\n");
+ }
+ sb.append(indent).append("}");
+ return sb.toString();
+ }
+ throw new IllegalArgumentException("Unsupported JSON value type: " + v.getClass());
+ }
+
+ private static String quote(String s) {
+ StringBuilder sb = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"' -> sb.append("\\\"");
+ case '\\' -> sb.append("\\\\");
+ case '\n' -> sb.append("\\n");
+ case '\r' -> sb.append("\\r");
+ case '\t' -> sb.append("\\t");
+ default -> {
+ if (c < 0x20) {
+ sb.append(String.format("\\u%04x", (int) c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ }
+ return sb.append("\"").toString();
+ }
+}
diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java
new file mode 100644
index 000000000..7c066032c
--- /dev/null
+++ b/sdk-integration-tests/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.DagOperations.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/sdk/src/main/java/software/amazon/lambda/durable/annotations/Experimental.java b/sdk/src/main/java/software/amazon/lambda/durable/annotations/Experimental.java
new file mode 100644
index 000000000..7bb111bab
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/annotations/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.annotations;
+
+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/sdk/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java
new file mode 100644
index 000000000..219189e1f
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.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 java.util.Objects;
+import java.util.function.Function;
+import software.amazon.lambda.durable.annotations.Experimental;
+
+/**
+ * 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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java
new file mode 100644
index 000000000..fa506179e
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.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.StepContext;
+import software.amazon.lambda.durable.annotations.Experimental;
+
+/**
+ * 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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java
new file mode 100644
index 000000000..a970de975
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java
@@ -0,0 +1,20 @@
+// 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;
+import software.amazon.lambda.durable.annotations.Experimental;
+
+/**
+ * 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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java
new file mode 100644
index 000000000..0e9a4b6f3
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java
@@ -0,0 +1,64 @@
+// 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.annotations.Experimental;
+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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java
new file mode 100644
index 000000000..03295cc0b
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.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.dag;
+
+import software.amazon.lambda.durable.annotations.Experimental;
+
+/**
+ * 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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java
new file mode 100644
index 000000000..a856f4479
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java
@@ -0,0 +1,20 @@
+// 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.annotations.Experimental;
+
+/**
+ * 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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java
new file mode 100644
index 000000000..03c96a3e8
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.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.annotations.Experimental;
+
+/**
+ * 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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java
new file mode 100644
index 000000000..f03b46ed3
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.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;
+
+import software.amazon.lambda.durable.annotations.Experimental;
+
+/**
+ * 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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java
new file mode 100644
index 000000000..8861f5b93
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java
@@ -0,0 +1,30 @@
+// 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 software.amazon.lambda.durable.annotations.Experimental;
+
+/**
+ * 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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java
new file mode 100644
index 000000000..782c585ce
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.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;
+import software.amazon.lambda.durable.annotations.Experimental;
+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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java
new file mode 100644
index 000000000..ba2748cc7
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java
@@ -0,0 +1,93 @@
+// 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.annotations.Experimental;
+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/sdk/src/main/java/software/amazon/lambda/durable/dag/DagContext.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagContext.java
new file mode 100644
index 000000000..2e4036502
--- /dev/null
+++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagContext.java
@@ -0,0 +1,110 @@
+// 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.annotations.Experimental;
+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);
+
+