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: + * + *

    + *
  1. 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. + *
  2. 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. + *
  3. An outer 2s {@code wait} that ends the invocation, forcing the next one to replay both completed containers. + *
  4. {@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 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); + + TaskHandle> map( + String name, Collection items, Class type, MapFunction fn, MapConfig config); + + TaskHandle> map( + String name, Function> items, Class type, MapFunction fn); + + TaskHandle> map( + String name, Function> items, Class type, MapFunction fn, MapConfig config); + + // ── parallel ────────────────────────────────────────────────────────────── + // NOTE: branches are declared against the SDK's existing ParallelDurableFuture (reused verbatim, no new + // ParallelBuilder type) — the scheduler applies the consumer to the launched parallel future. + TaskHandle parallel(String name, Consumer branches); + + TaskHandle parallel(String name, Consumer branches, ParallelConfig config); + + // ── nested dag ──────────────────────────────────────────────────────────── + TaskHandle dag(String name, Consumer register); + + TaskHandle dag(String name, Consumer register, DagConfig config); +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java new file mode 100644 index 000000000..b223fd106 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.annotations.Experimental; + +/** + * Thrown at registration when the declared task dependencies form a cycle. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagCyclicDependencyException extends DagException { + public DagCyclicDependencyException(String message) { + super(message); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java new file mode 100644 index 000000000..a852d043c --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.annotations.Experimental; + +/** + * Thrown at registration when two tasks are registered under the same name in the same DAG scope. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagDuplicateTaskException extends DagException { + public DagDuplicateTaskException(String message) { + super(message); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagException.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagException.java new file mode 100644 index 000000000..9ea743637 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagException.java @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.annotations.Experimental; +import software.amazon.lambda.durable.exception.DurableExecutionException; + +/** + * Base exception for DAG operations. DAG failures are extension-level errors rather than failures associated with one + * primitive operation. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagException extends DurableExecutionException { + public DagException(String message) { + super(message); + } + + public DagException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java new file mode 100644 index 000000000..f0650e702 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import software.amazon.lambda.durable.annotations.Experimental; + +/** + * Thrown by {@code DagResult.throwIfError()} when the DAG completed with at least one failed task. Wraps the first + * failed task's cause (when available). + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagExecutionException extends DagException { + public DagExecutionException(String message) { + super(message); + } + + /** + * Primary constructor: wraps the first failed task's cause. + * + *

Annotated as the {@code @JsonCreator} so that when this exception crosses a {@code runInChildContext} boundary + * (a nested DAG task that calls {@code throwIfError()} in its body) it is reconstructed with its cause set at + * construction time. This avoids {@code Throwable.initCause}, which the base hierarchy would otherwise reject + * because it pre-initializes the cause to {@code null} — the same idiom {@link DagPredicateException} uses. Without + * it, a cause-carrying {@code DagExecutionException} degrades to a bare {@code ChildContextFailedException} at the + * caller. Jackson passes {@code cause == null} for the no-cause form, which is equivalent to the single-arg + * constructor. + */ + @JsonCreator + public DagExecutionException(@JsonProperty("message") String message, @JsonProperty("cause") Throwable cause) { + super(message, cause); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java new file mode 100644 index 000000000..b56c68e17 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.annotations.Experimental; + +/** + * Thrown at registration when a task declares a dependency on a handle that was not registered in the same DAG scope. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagInvalidDependencyException extends DagException { + public DagInvalidDependencyException(String message) { + super(message); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java new file mode 100644 index 000000000..c4548dced --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.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; + +/** + * Thrown at registration when a task name violates the DAG charset rules ({@code ^[a-zA-Z0-9_]+$}, {@code <= 100} + * chars, must not contain the reserved sequence {@code DAG_NODE_T_}). + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public class DagInvalidTaskNameException extends DagException { + public DagInvalidTaskNameException(String message) { + super(message); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagOperations.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagOperations.java new file mode 100644 index 000000000..b07a92fae --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagOperations.java @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.Objects; +import java.util.function.Consumer; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.annotations.Experimental; +import software.amazon.lambda.durable.dag.internal.DagContextImpl; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.util.ParameterValidator; + +/** Static entry points for the experimental DAG extension operation. */ +@Experimental +public final class DagOperations { + private DagOperations() {} + + /** Declares and runs a DAG using the current durable extension context. */ + public static DagResult dag(String name, Consumer register) { + return dagAsync(name, register).get(); + } + + /** Declares and runs a configured DAG using the current durable extension context. */ + public static DagResult dag(String name, Consumer register, DagConfig config) { + return dagAsync(name, register, config).get(); + } + + /** Asynchronously declares and runs a DAG using the current durable extension context. */ + public static DurableFuture dagAsync(String name, Consumer register) { + return dagAsync(name, register, DagConfig.builder().build()); + } + + /** Asynchronously declares and runs a configured DAG using the current durable extension context. */ + public static DurableFuture dagAsync(String name, Consumer register, DagConfig config) { + Objects.requireNonNull(register, "register cannot be null"); + Objects.requireNonNull(config, "config cannot be null"); + ParameterValidator.validateOperationName(name); + + var context = ExtensionContext.getCurrentContext(); + var dagContext = DagContextImpl.registerAndValidate(register); + return DagContextImpl.start(context, context.reserve(name), dagContext, config); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java new file mode 100644 index 000000000..c66f61072 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import software.amazon.lambda.durable.annotations.Experimental; + +/** + * Produces the payload for a DAG invoke task from resolved upstream results ({@link Deps}). + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +@FunctionalInterface +public interface DagPayloadFunction { + Object apply(Deps deps); +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java new file mode 100644 index 000000000..253c83798 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import software.amazon.lambda.durable.annotations.Experimental; + +/** + * Thrown when a task's {@code runIf} predicate throws. A {@code runIf} predicate is specified as a synchronous, + * deterministic, pure function of resolved upstream results, re-evaluated on every replay and never checkpointed. A + * throw is therefore a defect in deterministic code, not a business outcome: the scheduler aborts the DAG + * (it starts no further tasks and the offending task is left with no terminal state — neither {@code FAILED} nor + * {@code SKIPPED}) and the {@code dag(...)} operation fails with this exception rather than recording a task failure + * that would drive {@code ALL_FAILED}/{@code ANY_FAILED}/{@code ALL_DONE} compensation paths. + * + *

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

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

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

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

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

The result is {@link Optional#empty()} whenever the upstream did not produce a success value — i.e. it FAILED + * or was SKIPPED. This is possible under non-ALL_SUCCESS trigger rules ({@code ALL_DONE}, {@code ANY_FAILED}, + * {@code NONE_FAILED}, {@code ALL_FAILED}), where a task can run even though one of its inline dependencies did not + * succeed. For the default {@code ALL_SUCCESS} trigger rule the value is always present, so callers may unwrap with + * {@link Optional#orElseThrow()}. + * + * @param the upstream task's result type + * @param handle the upstream task's handle (must be an inline dependency declared via {@code reads(...)}) + * @return the upstream result, or {@link Optional#empty()} if the upstream did not SUCCEED + * @throws IllegalStateException if {@code handle} was not declared as an inline dependency of this task + */ + Optional get(TaskHandle handle); +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java new file mode 100644 index 000000000..0a5af44e4 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/SkipReason.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; + +/** + * Explains why a DAG task was {@link TaskStatus#SKIPPED}. + * + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public enum SkipReason { + /** The task's {@link TriggerRule} was not satisfied by its upstream dependencies' terminal statuses. */ + TRIGGER_RULE, + /** The task's {@code runIf} predicate evaluated to {@code false}. */ + RUN_IF_PREDICATE +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java new file mode 100644 index 000000000..12e57a4ef --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.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 java.time.Instant; +import java.util.Optional; +import software.amazon.lambda.durable.annotations.Experimental; + +/** + * The recorded outcome of a single DAG task. + * + * @param the task result type + * @param name the task name + * @param status the terminal status + * @param skipReason present only when {@code status == SKIPPED} + * @param result present only when {@code status == SUCCEEDED} + * @param error present only when {@code status == FAILED} + * @param startedAt when the task started, when available; currently empty because the extension SPI does not expose + * operation timestamps + * @param completedAt when the task completed, when available; currently empty because the extension SPI does not expose + * operation timestamps + * @apiNote Experimental. This API is experimental and may be changed or removed in future releases without a + * major-version bump. + */ +@Experimental +public record TaskExecution( + String name, + TaskStatus status, + Optional skipReason, + Optional result, + Optional error, + Optional startedAt, + Optional completedAt) {} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java new file mode 100644 index 000000000..40f4f7adb --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java @@ -0,0 +1,58 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import java.util.function.Predicate; +import software.amazon.lambda.durable.annotations.Experimental; + +/** + * A registration-time reference to a DAG task, carrying the task's result type {@code T} via generics. Returned by each + * {@link DagContext} registration method and used to declare dependencies and retrieve results via {@link Deps}. + * + *

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

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

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

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

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

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

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

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

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

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

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

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

Each rule defines its behavior for the empty-upstream (vacuous) case used by root tasks or tasks whose dependency + * set is empty. + */ +final class TriggerRuleEvaluator { + + private TriggerRuleEvaluator() {} + + /** + * Evaluates a rule against the terminal statuses of a task's upstream dependencies. + * + * @param rule the trigger rule + * @param statuses the terminal statuses of the upstream dependencies (may be empty) + * @return {@code true} if the task should run, {@code false} if it should be skipped + */ + static boolean eval(TriggerRule rule, List statuses) { + return switch (rule) { + // Empty upstream: vacuously true. + case ALL_SUCCESS -> statuses.stream().allMatch(s -> s == TaskStatus.SUCCEEDED); + // Empty upstream: skip. + case ALL_FAILED -> !statuses.isEmpty() && statuses.stream().allMatch(s -> s == TaskStatus.FAILED); + // Empty upstream: run. + case ALL_DONE -> true; + // Empty upstream: skip. + case ANY_SUCCESS -> statuses.stream().anyMatch(s -> s == TaskStatus.SUCCEEDED); + // Empty upstream: skip. + case ANY_FAILED -> statuses.stream().anyMatch(s -> s == TaskStatus.FAILED); + // Empty upstream: run. + case NONE_FAILED -> statuses.stream().noneMatch(s -> s == TaskStatus.FAILED); + }; + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java index aaf2b701c..c5d00d042 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java @@ -90,7 +90,39 @@ public static DurableFuture> mapAsync( Objects.requireNonNull(config, "config cannot be null"); ParameterValidator.validateOperationName(name); ParameterValidator.validateOrderedCollection(items); + var preparedMap = prepareMap(context, name, items, config); + return startMap(context, context.reserve(name), name, preparedMap, resultType, function); + } + + /** + * Creates a map using an operation identity reserved by an extension scheduler. + * + * @param context the active extension context + * @param parent the reserved identity for the map container + * @param name the map operation name + */ + public static DurableFuture> mapAsync( + ExtensionContext context, + ExtensionOperation parent, + String name, + Collection items, + TypeToken resultType, + DurableContext.MapFunction function, + MapConfig config) { + Objects.requireNonNull(context, "context cannot be null"); + Objects.requireNonNull(parent, "parent cannot be null"); + Objects.requireNonNull(items, "items cannot be null"); + Objects.requireNonNull(function, "function cannot be null"); + Objects.requireNonNull(resultType, "resultType cannot be null"); + Objects.requireNonNull(config, "config cannot be null"); + ParameterValidator.validateOperationName(name); + ParameterValidator.validateOrderedCollection(items); + var preparedMap = prepareMap(context, name, items, config); + return startMap(context, parent, name, preparedMap, resultType, function); + } + private static PreparedMap prepareMap( + ExtensionContext context, String name, Collection items, MapConfig config) { if (config.serDes() == null) { config = config.toBuilder() .serDes(context.getDurableConfig().getSerDes()) @@ -98,10 +130,20 @@ public static DurableFuture> mapAsync( } var itemList = List.copyOf(items); var iterationNames = resolveIterationNames(name, itemList, config); - var parent = context.reserve(name); validateMinSuccessful(itemList, config); + return new PreparedMap<>(itemList, iterationNames, config); + } - var mapConfig = config; + private static DurableFuture> startMap( + ExtensionContext context, + ExtensionOperation parent, + String name, + PreparedMap preparedMap, + TypeToken resultType, + DurableContext.MapFunction function) { + var itemList = preparedMap.items(); + var iterationNames = preparedMap.iterationNames(); + var mapConfig = preparedMap.config(); var virtualEmptyMap = itemList.isEmpty() && !context.getDurableConfig().shouldCheckpointEmptyMap(); var parentConfig = parentContextConfig(mapConfig.serDes(), virtualEmptyMap).toBuilder() .validateCompletedReplay(true) @@ -362,6 +404,8 @@ private static void restore(MapItemContext previous) { } } + private record PreparedMap(List items, List iterationNames, MapConfig config) {} + /** Configuration for durable MAP operations. */ public static final class MapConfig { private final int maxConcurrency; diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableParallelOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableParallelOperation.java index 865344091..6adf28157 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableParallelOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableParallelOperation.java @@ -21,6 +21,7 @@ import software.amazon.lambda.durable.extension.ExtensionContextConfig; import software.amazon.lambda.durable.extension.ExtensionContextReplayContext; import software.amazon.lambda.durable.extension.ExtensionContextResult; +import software.amazon.lambda.durable.extension.ExtensionOperation; import software.amazon.lambda.durable.model.ParallelResult; import software.amazon.lambda.durable.serde.SerDes; import software.amazon.lambda.durable.util.ParameterValidator; @@ -41,7 +42,22 @@ public static ParallelDurableFuture parallel(ExtensionContext context, String na Objects.requireNonNull(context, "context cannot be null"); Objects.requireNonNull(config, "config cannot be null"); ParameterValidator.validateOperationName(name); - return new ParallelOperationFuture(context, name, config); + return parallel(context, context.reserve(name), config); + } + + /** + * Creates a parallel operation using an identity reserved by an extension scheduler. + * + * @param context the active extension context + * @param parent the reserved identity for the parallel container + * @param config the parallel configuration + */ + public static ParallelDurableFuture parallel( + ExtensionContext context, ExtensionOperation parent, ParallelConfig config) { + Objects.requireNonNull(context, "context cannot be null"); + Objects.requireNonNull(parent, "parent cannot be null"); + Objects.requireNonNull(config, "config cannot be null"); + return new ParallelOperationFuture(context, parent, config); } private static final class ParallelOperationFuture implements ParallelDurableFuture { @@ -55,10 +71,9 @@ private static final class ParallelOperationFuture implements ParallelDurableFut private ParallelResult replayState; private boolean registrationClosed; - ParallelOperationFuture(ExtensionContext context, String name, ParallelConfig config) { + ParallelOperationFuture(ExtensionContext context, ExtensionOperation parent, ParallelConfig config) { this.config = config; defaultSerDes = context.getDurableConfig().getSerDes(); - var parent = context.reserve(name); parentFuture = parent.runInChildContextAsync( PARALLEL.getValue(), parallelResultType(), diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperation.java index 7ed460ec9..2a1c0fe09 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperation.java @@ -12,6 +12,7 @@ import software.amazon.lambda.durable.exception.StepFailedException; import software.amazon.lambda.durable.exception.WaitForConditionFailedException; import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.extension.ExtensionOperation; import software.amazon.lambda.durable.extension.ExtensionStepConfig; import software.amazon.lambda.durable.extension.ExtensionStepResult; import software.amazon.lambda.durable.model.OperationSubType; @@ -92,16 +93,34 @@ public static DurableFuture waitForConditionAsync( Objects.requireNonNull(checkFunction, "checkFunction cannot be null"); Objects.requireNonNull(config, "config cannot be null"); ParameterValidator.validateOperationName(name); + return waitForConditionAsync(context.reserve(name), resultType, checkFunction, config); + } - var future = context.reserve(name) - .stepAsync( - OperationSubType.WAIT_FOR_CONDITION.getValue(), - resultType, - state -> evaluate(state, checkFunction, config), - ExtensionStepConfig.builder() - .initialState(config.initialState()) - .serDes(config.serDes()) - .build()); + /** + * Creates a wait-for-condition operation using an identity reserved by an extension scheduler. + * + * @param operation the reserved step identity + * @param resultType the state and result type + * @param checkFunction the condition function + * @param config the wait-for-condition configuration + */ + public static DurableFuture waitForConditionAsync( + ExtensionOperation operation, + TypeToken resultType, + BiFunction> checkFunction, + WaitForConditionConfig config) { + Objects.requireNonNull(operation, "operation cannot be null"); + Objects.requireNonNull(resultType, "resultType cannot be null"); + Objects.requireNonNull(checkFunction, "checkFunction cannot be null"); + Objects.requireNonNull(config, "config cannot be null"); + var future = operation.stepAsync( + OperationSubType.WAIT_FOR_CONDITION.getValue(), + resultType, + state -> evaluate(state, checkFunction, config), + ExtensionStepConfig.builder() + .initialState(config.initialState()) + .serDes(config.serDes()) + .build()); return new WaitForConditionFuture<>(future); } diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java b/sdk/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java new file mode 100644 index 000000000..ddb4364e3 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.serde.JacksonSerDes; + +/** Unit tests for {@link DagExecutionException} and its cross-boundary (SerDes) reconstruction. */ +class DagExecutionExceptionTest { + + @Test + void survivesSerDesRoundTripPreservingTypeAndCause() { + // throwIfError() can be invoked inside a nested DAG task body, so a cause-carrying DagExecutionException can + // cross the child-context boundary. Reconstruction only works because of the @JsonCreator that sets the cause + // at construction (the base hierarchy pre-initializes the cause to null, so Jackson's default initCause path + // fails) — the same idiom DagPredicateException uses. Without it the type erases to + // ChildContextFailedException. + var serDes = new JacksonSerDes(); + var original = new DagExecutionException("DAG had 1 failed task", new IllegalStateException("task failed")); + + String json = serDes.serialize(original); + var restored = serDes.deserialize(json, TypeToken.get(DagExecutionException.class)); + + assertNotNull(restored); + assertEquals(original.getMessage(), restored.getMessage()); + assertNotNull(restored.getCause(), "cause must survive the round trip"); + assertEquals("task failed", restored.getCause().getMessage()); + } + + @Test + void survivesSerDesRoundTripWithoutCause() { + var serDes = new JacksonSerDes(); + var original = new DagExecutionException("DAG had 1 failed task"); + + String json = serDes.serialize(original); + var restored = serDes.deserialize(json, TypeToken.get(DagExecutionException.class)); + + assertNotNull(restored); + assertEquals(original.getMessage(), restored.getMessage()); + assertTrue(restored.getCause() == null, "no cause was set"); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/DagOperationsTest.java b/sdk/src/test/java/software/amazon/lambda/durable/dag/DagOperationsTest.java new file mode 100644 index 000000000..9171518a5 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/dag/DagOperationsTest.java @@ -0,0 +1,142 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.lambda.durable.DurableConfig; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.config.StepSemantics; +import software.amazon.lambda.durable.context.BaseContextImpl; +import software.amazon.lambda.durable.dag.internal.DagResultSerDes; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.extension.ExtensionContextConfig; +import software.amazon.lambda.durable.extension.ExtensionContextFunction; +import software.amazon.lambda.durable.extension.ExtensionOperation; +import software.amazon.lambda.durable.extension.ExtensionStepConfig; +import software.amazon.lambda.durable.extension.ExtensionStepFunction; +import software.amazon.lambda.durable.extension.ExtensionStepResult; +import software.amazon.lambda.durable.retry.RetryDecision; +import software.amazon.lambda.durable.serde.JacksonSerDes; + +class DagOperationsTest { + @Test + void dagUsesExtensionReservationsForContainerAndTasks() { + var context = mock(ExtensionContext.class); + var parent = mock(ExtensionOperation.class); + var parentFuture = mockDagFuture(); + var serDes = new JacksonSerDes(); + var retryDelay = Duration.ofSeconds(3); + var stepConfig = StepConfig.builder() + .serDes(serDes) + .retryStrategy((error, attempt) -> RetryDecision.retry(retryDelay)) + .semanticsPerRetry(StepSemantics.AT_MOST_ONCE_PER_RETRY) + .build(); + when(context.getDurableConfig()) + .thenReturn(DurableConfig.builder().withSerDes(serDes).build()); + when(context.reserve("graph")).thenReturn(parent); + when(parent.runInChildContextAsync( + eq("Dag"), + eq(TypeToken.get(DagResult.class)), + any(ExtensionContextFunction.class), + any(ExtensionContextConfig.class))) + .thenReturn(parentFuture); + + DurableFuture actual; + try (var ignored = BaseContextImpl.attachCurrentContext(context)) { + actual = DagOperations.dagAsync( + "graph", dag -> dag.step("node", String.class, (deps, step) -> "done", stepConfig)); + } + + assertSame(parentFuture, actual); + var function = extensionFunction(); + var extensionConfig = ArgumentCaptor.forClass(ExtensionContextConfig.class); + verify(parent) + .runInChildContextAsync( + eq("Dag"), eq(TypeToken.get(DagResult.class)), function.capture(), extensionConfig.capture()); + assertInstanceOf(DagResultSerDes.class, extensionConfig.getValue().serDes()); + verify(context).reserve("graph"); + + var child = mock(ExtensionContext.class); + var task = mock(ExtensionOperation.class); + var taskConfig = ArgumentCaptor.forClass(ExtensionStepConfig.class); + when(child.reserve("node", "DAG_NODE_T_node")).thenReturn(task); + when(task.stepAsync( + eq("Step"), + eq(TypeToken.get(String.class)), + any(ExtensionStepFunction.class), + taskConfig.capture())) + .thenReturn(new CompletedFuture<>("done")); + + DagResult result; + try (var ignored = BaseContextImpl.attachCurrentContext(child)) { + result = function.getValue().apply().result(); + } + + assertEquals("done", result.getResult("node").orElseThrow()); + assertSame(serDes, taskConfig.getValue().serDes()); + assertEquals( + ExtensionStepConfig.StepSemantics.AT_MOST_ONCE_PER_RETRY, + taskConfig.getValue().semanticsPerRetry()); + var retryDecision = + taskConfig.getValue().retryStrategy().makeRetryDecision(new IllegalStateException("retry"), "state", 1); + var retry = assertInstanceOf(ExtensionStepResult.Retry.class, retryDecision); + assertEquals("state", retry.state()); + assertEquals(retryDelay, retry.delay()); + verify(child).reserve("node", "DAG_NODE_T_node"); + verify(child, never()).reserve("node"); + } + + @Test + void invalidNameFailsBeforeRegistration() { + var context = mock(ExtensionContext.class); + var registrationCalled = new AtomicBoolean(); + + try (var ignored = BaseContextImpl.attachCurrentContext(context)) { + assertThrows( + IllegalArgumentException.class, + () -> DagOperations.dagAsync("", dag -> registrationCalled.set(true))); + } + + assertEquals(false, registrationCalled.get()); + verify(context, never()).reserve(any()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private ArgumentCaptor> extensionFunction() { + return (ArgumentCaptor) ArgumentCaptor.forClass(ExtensionContextFunction.class); + } + + @SuppressWarnings("unchecked") + private DurableFuture mockDagFuture() { + return mock(DurableFuture.class); + } + + private record CompletedFuture(T result) implements DurableFuture { + @Override + public T get() { + return result; + } + + @Override + public CompletableFuture completionFuture() { + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java b/sdk/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java new file mode 100644 index 000000000..f4433a0a4 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.exception.DurableExecutionException; +import software.amazon.lambda.durable.serde.JacksonSerDes; + +/** Unit tests for {@link DagPredicateException} and its cross-boundary (SerDes) reconstruction. */ +class DagPredicateExceptionTest { + + @Test + void carriesTaskNameMessageAndCause() { + var cause = new IllegalStateException("predicate boom"); + var ex = new DagPredicateException("maybe", cause); + + assertEquals("maybe", ex.taskName()); + assertEquals(cause, ex.getCause()); + assertTrue(ex.getMessage().contains("maybe"), "message must name the task"); + assertTrue(ex.getMessage().contains("predicate boom"), "message must identify the original error"); + assertInstanceOf(DagException.class, ex); + assertInstanceOf(DurableExecutionException.class, ex); + } + + @Test + void survivesSerDesRoundTripPreservingTypeAndCause() { + // This is the round-trip the DAG child-context boundary relies on: the typed exception is serialized on the + // failing side and reconstructed on the caller side. The reconstructed exception must remain a + // DagPredicateException whose message and taskName name the task and whose cause is the reconstructed original + // error. + var serDes = new JacksonSerDes(); + var original = new DagPredicateException("maybe", new IllegalStateException("predicate boom")); + + String json = serDes.serialize(original); + var restored = serDes.deserialize(json, TypeToken.get(DagPredicateException.class)); + + assertNotNull(restored); + assertEquals(original.getMessage(), restored.getMessage()); + assertTrue(restored.getMessage().contains("maybe")); + assertEquals("maybe", restored.taskName()); + // The cause is retrievable with its original message and stack trace preserved. Its concrete Java type + // degrades to Throwable across the durable boundary: the SDK serializes a nested cause without polymorphic + // type info, so only a top-level exception's concrete type is recoverable (via ErrorObject.errorType). This is + // a general property of the SDK's exception serialization, not specific to runIf. + assertNotNull(restored.getCause()); + assertEquals("predicate boom", restored.getCause().getMessage()); + assertTrue(restored.getCause().getStackTrace().length > 0, "cause stack trace must be preserved"); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java new file mode 100644 index 000000000..bc0513f3e --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java @@ -0,0 +1,255 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.dag.DagCompletionReason; +import software.amazon.lambda.durable.dag.DagExecutionException; +import software.amazon.lambda.durable.dag.DagResult; +import software.amazon.lambda.durable.dag.DagTaskError; +import software.amazon.lambda.durable.dag.SkipReason; +import software.amazon.lambda.durable.dag.TaskExecution; +import software.amazon.lambda.durable.dag.TaskStatus; +import software.amazon.lambda.durable.serde.JacksonSerDes; + +/** + * Pins the cross-language DAG container envelope produced by the Java SDK, in both the inline (with {@code tasks}) and + * offloaded (without {@code tasks}) cases, for a three-task DAG with one success, one failure and one skip. Also pins + * the lowercase {@code resultKind} vocabulary, the PascalCase error object, and additive-only unknown-field tolerance. + */ +class DagEnvelopeConvergenceTest { + + private static final Path OUT_DIR = + Path.of(System.getProperty("dag.envelope.outputDir", "target/dag-envelope-out")); + + private static final Instant T0 = Instant.parse("2026-07-26T03:19:01.884Z"); + private static final Instant T1 = Instant.parse("2026-07-26T03:19:01.885Z"); + private static final Instant T2 = Instant.parse("2026-07-26T03:19:02.010Z"); + private static final Instant T3 = Instant.parse("2026-07-26T03:19:02.140Z"); + + /** The canonical three-task DAG: load-order SUCCEEDED, charge-card FAILED, ship SKIPPED. */ + private static DagResult threeTaskDag() { + Map> m = new LinkedHashMap<>(); + m.put( + "load-order", + new TaskExecution<>( + "load-order", + TaskStatus.SUCCEEDED, + Optional.empty(), + Optional.of("order-42"), + Optional.empty(), + Optional.of(T0), + Optional.of(T1))); + m.put( + "charge-card", + new TaskExecution<>( + "charge-card", + TaskStatus.FAILED, + Optional.empty(), + Optional.empty(), + Optional.of(new DagTaskError( + "java.lang.RuntimeException", + "card declined", + List.of( + "com.example.Orders.charge(Orders.java:42)", + "com.example.Orders.run(Orders.java:17)"))), + Optional.of(T2), + Optional.of(T3))); + m.put( + "ship", + new TaskExecution<>( + "ship", + TaskStatus.SKIPPED, + Optional.of(SkipReason.TRIGGER_RULE), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty())); + return new DagResultImpl(m, DagCompletionReason.COMPLETED_WITH_FAILURES, 3, List.of()); + } + + @Test + void inlineEnvelopePinsExactShape() throws Exception { + var serdes = new DagResultSerDes(new JacksonSerDes()); + String json = serdes.serialize(threeTaskDag()); + writeArtifact("inline.json", json); + + var root = new ObjectMapper().readTree(json); + assertEquals("DagResult", root.get("type").asText()); + assertEquals(3, root.get("totalCount").asInt()); + assertEquals(1, root.get("successCount").asInt()); + assertEquals(1, root.get("failureCount").asInt()); + assertEquals(1, root.get("skippedCount").asInt()); + assertEquals("COMPLETED_WITH_FAILURES", root.get("completionReason").asText()); + assertTrue(root.get("startedTaskNames").isArray() + && root.get("startedTaskNames").isEmpty()); + assertEquals(1, root.get("failedTaskNames").size()); + assertEquals("charge-card", root.get("failedTaskNames").get(0).asText()); + assertTrue(root.has("tasks"), "inline envelope must carry tasks"); + assertEquals(3, root.get("tasks").size()); + + var load = root.get("tasks").get(0); + assertEquals("load-order", load.get("name").asText()); + assertEquals("SUCCEEDED", load.get("status").asText()); + assertTrue(load.get("skipReason").isNull()); + assertEquals("plain", load.get("resultKind").asText()); + assertEquals("order-42", load.get("result").asText()); + assertTrue(load.get("error").isNull()); + assertEquals("2026-07-26T03:19:01.884Z", load.get("startedAt").asText()); + assertEquals("2026-07-26T03:19:01.885Z", load.get("completedAt").asText()); + + var charge = root.get("tasks").get(1); + assertEquals("charge-card", charge.get("name").asText()); + assertEquals("FAILED", charge.get("status").asText()); + // resultKind is null when there is no result to interpret, and the KEY is still + // present: rule 1 says absent values are explicit null, never omitted. + assertTrue(charge.has("resultKind"), "resultKind key must be present"); + assertTrue(charge.get("resultKind").isNull(), "FAILED task carries resultKind null"); + assertTrue(charge.has("result")); + assertTrue(charge.get("result").isNull()); + // Error object is PascalCase. + var err = charge.get("error"); + assertEquals("java.lang.RuntimeException", err.get("ErrorType").asText()); + assertEquals("card declined", err.get("ErrorMessage").asText()); + assertEquals(2, err.get("StackTrace").size()); + + var ship = root.get("tasks").get(2); + assertEquals("SKIPPED", ship.get("status").asText()); + assertEquals("TRIGGER_RULE", ship.get("skipReason").asText()); + assertTrue(ship.has("resultKind"), "resultKind key must be present"); + assertTrue(ship.get("resultKind").isNull(), "SKIPPED task carries resultKind null"); + assertTrue(ship.has("error")); + assertTrue(ship.get("error").isNull()); + assertTrue(ship.get("startedAt").isNull()); + assertTrue(ship.get("completedAt").isNull()); + } + + @Test + void offloadedEnvelopeDropsTasksButKeepsAggregate() throws Exception { + var serdes = new DagResultSerDes(new JacksonSerDes()); + List ladder = serdes.offloadPayloads(threeTaskDag()); + // Step 2: tasks dropped, failedTaskNames kept. + String offloaded = ladder.get(0); + writeArtifact("offloaded.json", offloaded); + var root = new ObjectMapper().readTree(offloaded); + assertFalse(root.has("tasks"), "offloaded envelope must NOT carry tasks (absence is the signal)"); + assertEquals("DagResult", root.get("type").asText()); + assertEquals(3, root.get("totalCount").asInt()); + assertEquals(1, root.get("successCount").asInt()); + assertEquals(1, root.get("failureCount").asInt()); + assertEquals(1, root.get("skippedCount").asInt()); + assertEquals("COMPLETED_WITH_FAILURES", root.get("completionReason").asText()); + assertTrue(root.get("startedTaskNames").isArray()); + assertEquals("charge-card", root.get("failedTaskNames").get(0).asText()); + + // Step 3 (last resort): failedTaskNames also dropped; counts/reason/startedTaskNames still present. + var root2 = new ObjectMapper().readTree(ladder.get(1)); + assertFalse(root2.has("tasks")); + assertFalse(root2.has("failedTaskNames")); + assertTrue(root2.has("totalCount")); + assertTrue(root2.has("completionReason")); + assertTrue(root2.has("startedTaskNames")); + } + + @Test + void restoringOffloadedEnvelopeWithFailuresPreservesCountsAndReason() { + // Contract rule 1: restoring a tasks-less (offloaded) envelope MUST preserve totalCount, the three counts and + // completionReason. It must NEVER fabricate ALL_COMPLETED / zeroed counts when the envelope says otherwise. + var serdes = new DagResultSerDes(new JacksonSerDes()); + // Step-2 offload payload: tasks dropped, failedTaskNames kept. + String offloaded = serdes.offloadPayloads(threeTaskDag()).get(0); + + DagResult restored = serdes.deserialize(offloaded, TypeToken.get(DagResult.class)); + + // The per-task map is legitimately empty (tasks were offloaded)... + assertTrue(restored.results().isEmpty(), "offloaded restore has no per-task detail in the map"); + // ...but the aggregate is honest, NOT a fabricated empty success. + assertEquals(DagCompletionReason.COMPLETED_WITH_FAILURES, restored.completionReason()); + assertFalse( + restored.completionReason() == DagCompletionReason.ALL_COMPLETED, + "must not report ALL_COMPLETED for a DAG that had failures"); + assertEquals(3, restored.totalCount()); + assertEquals(1, restored.successCount()); + assertEquals(1, restored.failureCount()); + assertEquals(1, restored.skippedCount()); + // A caller must never be told the DAG succeeded when the checkpoint says it did not. + assertThrows(DagExecutionException.class, restored::throwIfError); + } + + @Test + void restoringLastResortOffloadEnvelopePreservesCountsAndReason() { + // The smallest offload candidate additionally drops failedTaskNames; counts, reason and totalCount must still + // survive the round-trip. + var serdes = new DagResultSerDes(new JacksonSerDes()); + String lastResort = serdes.offloadPayloads(threeTaskDag()).get(1); + + DagResult restored = serdes.deserialize(lastResort, TypeToken.get(DagResult.class)); + + assertEquals(DagCompletionReason.COMPLETED_WITH_FAILURES, restored.completionReason()); + assertEquals(3, restored.totalCount()); + assertEquals(1, restored.successCount()); + assertEquals(1, restored.failureCount()); + assertEquals(1, restored.skippedCount()); + } + + @Test + void unknownExtraFieldDeserializesWithoutError() { + // Additive-only evolution (no schemaVersion): a reader MUST ignore an unknown field rather than fail. + var serdes = new DagResultSerDes(new JacksonSerDes()); + String json = serdes.serialize(threeTaskDag()); + // Inject an unknown top-level field and an unknown per-task field. + String withExtra = json.replaceFirst("\\{", "{\"futureField\":{\"nested\":true},") + .replaceFirst("\"name\":\"load-order\"", "\"name\":\"load-order\",\"futureTaskField\":123"); + + DagResult restored = serdes.deserialize(withExtra, TypeToken.get(DagResult.class)); + assertEquals(3, restored.totalCount()); + assertEquals(1, restored.successCount()); + assertEquals(1, restored.failureCount()); + assertEquals(1, restored.skippedCount()); + assertEquals(DagCompletionReason.COMPLETED_WITH_FAILURES, restored.completionReason()); + assertEquals("order-42", restored.getResult("load-order").orElseThrow()); + assertEquals(TaskStatus.SKIPPED, restored.getStatus("ship").orElseThrow()); + } + + @Test + void inlineEnvelopeRoundTrips() { + var serdes = new DagResultSerDes(new JacksonSerDes()); + var restored = serdes.deserialize(serdes.serialize(threeTaskDag()), TypeToken.get(DagResult.class)); + assertEquals(3, restored.totalCount()); + assertEquals(1, restored.successCount()); + assertEquals(1, restored.failureCount()); + assertEquals(1, restored.skippedCount()); + assertEquals(List.of("charge-card"), restored.failedTaskNames()); + var charge = restored.failed().get(0).error().orElseThrow(); + assertEquals("java.lang.RuntimeException", charge.errorType()); + assertEquals("card declined", charge.errorMessage()); + assertEquals(2, charge.stackTrace().size()); + var load = restored.results().get("load-order"); + assertEquals(Optional.of(T0), load.startedAt()); + assertEquals(Optional.of(T1), load.completedAt()); + } + + private static void writeArtifact(String name, String json) throws Exception { + // Pretty-print for the human-readable report artifact; assertions above run on the compact production bytes. + var mapper = new ObjectMapper(); + String pretty = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(mapper.readTree(json)); + Files.createDirectories(OUT_DIR); + Files.writeString(OUT_DIR.resolve(name), pretty + "\n", StandardCharsets.UTF_8); + Files.writeString(OUT_DIR.resolve(name.replace(".json", ".compact.json")), json + "\n", StandardCharsets.UTF_8); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java new file mode 100644 index 000000000..f7df31850 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java @@ -0,0 +1,228 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.dag.DagCompletionReason; +import software.amazon.lambda.durable.dag.DagExecutionException; +import software.amazon.lambda.durable.dag.DagTaskError; +import software.amazon.lambda.durable.dag.SkipReason; +import software.amazon.lambda.durable.dag.TaskExecution; +import software.amazon.lambda.durable.dag.TaskStatus; +import software.amazon.lambda.durable.serde.JacksonSerDes; + +class DagResultTest { + + private static TaskExecution ok(String name, Object value) { + return new TaskExecution<>( + name, + TaskStatus.SUCCEEDED, + Optional.empty(), + Optional.ofNullable(value), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + + private static TaskExecution fail(String name, DagTaskError err) { + return new TaskExecution<>( + name, + TaskStatus.FAILED, + Optional.empty(), + Optional.empty(), + Optional.of(err), + Optional.empty(), + Optional.empty()); + } + + private static TaskExecution skip(String name) { + return new TaskExecution<>( + name, + TaskStatus.SKIPPED, + Optional.of(SkipReason.TRIGGER_RULE), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + + private static DagResultImpl sample() { + Map> m = new LinkedHashMap<>(); + m.put("a", ok("a", "hello")); + m.put("b", ok("b", 42)); + m.put("c", skip("c")); + return new DagResultImpl(m, DagCompletionReason.ALL_COMPLETED); + } + + @Test + void countsAndAccessors() { + var r = sample(); + assertEquals(3, r.totalCount()); + assertEquals(2, r.successCount()); + assertEquals(1, r.skippedCount()); + assertEquals(0, r.failureCount()); + assertEquals(Optional.of("hello"), r.getResult("a")); + assertEquals(Optional.of(TaskStatus.SKIPPED), r.getStatus("c")); + assertTrue(r.getResult("c").isEmpty()); + } + + @Test + void throwIfErrorThrowsOnFailure() { + Map> m = new LinkedHashMap<>(); + m.put("a", ok("a", "x")); + m.put("bad", fail("bad", DagTaskError.of(new RuntimeException("boom")))); + var r = new DagResultImpl(m, DagCompletionReason.COMPLETED_WITH_FAILURES); + assertEquals(1, r.failureCount()); + assertThrows(DagExecutionException.class, r::throwIfError); + } + + @Test + void throwIfErrorNoopWhenClean() { + sample().throwIfError(); + } + + @Test + void serdeRoundTripPreservesStructure() { + var serdes = new DagResultSerDes(new JacksonSerDes()); + var original = sample(); + var json = serdes.serialize(original); + var restored = serdes.deserialize(json, TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + assertEquals(3, restored.totalCount()); + assertEquals(2, restored.successCount()); + assertEquals(1, restored.skippedCount()); + assertEquals(Optional.of("hello"), restored.getResult("a")); + assertEquals(42, ((Number) restored.getResult("b").orElseThrow()).intValue()); + assertEquals(Optional.of(TaskStatus.SKIPPED), restored.getStatus("c")); + assertEquals(DagCompletionReason.ALL_COMPLETED, restored.completionReason()); + } + + @Test + void emptyDagResultHasZeroCounts() { + var r = new DagResultImpl(new LinkedHashMap<>(), DagCompletionReason.ALL_COMPLETED); + assertEquals(0, r.totalCount()); + assertEquals(0, r.successCount()); + assertEquals(0, r.failureCount()); + assertEquals(0, r.skippedCount()); + assertTrue(r.getResult("nope").isEmpty()); + assertTrue(r.getStatus("nope").isEmpty()); + r.throwIfError(); // must not throw + } + + @Test + void serdeRoundTripRehydratesMapResultTask() { + var serdes = new DagResultSerDes(new JacksonSerDes()); + var mapResult = new software.amazon.lambda.durable.model.MapResult<>( + java.util.List.of( + software.amazon.lambda.durable.model.MapResult.MapResultItem.succeeded("x"), + software.amazon.lambda.durable.model.MapResult.MapResultItem.succeeded("y")), + software.amazon.lambda.durable.model.ConcurrencyCompletionStatus.ALL_COMPLETED); + Map> m = new LinkedHashMap<>(); + m.put("m", ok("m", mapResult)); + var original = new DagResultImpl(m, DagCompletionReason.ALL_COMPLETED); + + var restored = serdes.deserialize( + serdes.serialize(original), TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + + var rehydrated = restored.getResult("m").orElseThrow(); + assertTrue( + rehydrated instanceof software.amazon.lambda.durable.model.MapResult, + "MAP result must rehydrate to a MapResult instance, was: " + rehydrated.getClass()); + var mr = (software.amazon.lambda.durable.model.MapResult) rehydrated; + assertEquals(2, mr.size()); + assertEquals("x", mr.getResult(0)); + assertEquals("y", mr.getResult(1)); + } + + @Test + void serdeRoundTripRehydratesNestedDagResultTask() { + var serdes = new DagResultSerDes(new JacksonSerDes()); + Map> inner = new LinkedHashMap<>(); + inner.put("leaf", ok("leaf", "deep")); + var innerDag = new DagResultImpl(inner, DagCompletionReason.ALL_COMPLETED); + + Map> outer = new LinkedHashMap<>(); + outer.put("nested", ok("nested", innerDag)); + var original = new DagResultImpl(outer, DagCompletionReason.ALL_COMPLETED); + + var restored = serdes.deserialize( + serdes.serialize(original), TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + + var rehydrated = restored.getResult("nested").orElseThrow(); + assertTrue( + rehydrated instanceof software.amazon.lambda.durable.dag.DagResult, + "DAG result must rehydrate to a DagResult instance, was: " + rehydrated.getClass()); + var nested = (software.amazon.lambda.durable.dag.DagResult) rehydrated; + assertEquals(1, nested.totalCount()); + assertEquals(Optional.of("deep"), nested.getResult("leaf")); + assertEquals(DagCompletionReason.ALL_COMPLETED, nested.completionReason()); + } + + /** Simple record used to prove PLAIN typed-result fidelity across a serde round-trip. */ + public record Point(int x, int y) {} + + @Test + void serdeRoundTripPreservesPlainPojoType() { + // The type graph maps the task name to its declared result type, so PLAIN reconstruction into the concrete + // type is permitted (recovered by name from the registered graph, not from a checkpoint-stored class name). + var serdes = new DagResultSerDes( + new JacksonSerDes(), new DagResultTypes(Map.of("p", TypeToken.get(Point.class)), Map.of())); + Map> m = new LinkedHashMap<>(); + m.put("p", ok("p", new Point(3, 4))); + var original = new DagResultImpl(m, DagCompletionReason.ALL_COMPLETED); + + var restored = serdes.deserialize( + serdes.serialize(original), TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + + var rehydrated = restored.getResult("p").orElseThrow(); + assertTrue( + rehydrated instanceof Point, + "PLAIN POJO result must rehydrate to its concrete type, not a generic map; was: " + + rehydrated.getClass()); + assertEquals(new Point(3, 4), rehydrated); + } + + @Test + void serdePlainResultUnknownTaskFallsBackToGenericTree() { + // A PLAIN result whose task is NOT in the type graph (here: empty) is never reconstructed into a concrete + // type — no class is resolved from the checkpoint and the value degrades to a generic JSON tree. This is the + // regression guard for the former checkpoint-sourced arbitrary-class-load hazard (there is now no + // Class.forName on any checkpoint string at all). + var serdes = new DagResultSerDes(new JacksonSerDes(), DagResultTypes.empty()); + Map> m = new LinkedHashMap<>(); + m.put("p", ok("p", new Point(3, 4))); + var original = new DagResultImpl(m, DagCompletionReason.ALL_COMPLETED); + + var restored = serdes.deserialize( + serdes.serialize(original), TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + + var rehydrated = restored.getResult("p").orElseThrow(); + assertFalse( + rehydrated instanceof Point, + "A task absent from the type graph must NOT be reconstructed; expected a generic tree, got: " + + rehydrated.getClass()); + } + + @Test + void serdeRoundTripReconstructsError() { + var serdes = new DagResultSerDes(new JacksonSerDes()); + Map> m = new LinkedHashMap<>(); + m.put("bad", fail("bad", DagTaskError.of(new IllegalStateException("nope")))); + var original = new DagResultImpl(m, DagCompletionReason.COMPLETED_WITH_FAILURES); + var restored = serdes.deserialize( + serdes.serialize(original), TypeToken.get(software.amazon.lambda.durable.dag.DagResult.class)); + assertEquals(1, restored.failureCount()); + var err = restored.failed().get(0).error().orElseThrow(); + assertEquals(IllegalStateException.class.getName(), err.errorType()); + assertEquals("nope", err.errorMessage()); + assertFalse(err.cause().isPresent()); // cause is not serialized + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java new file mode 100644 index 000000000..89303de63 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.dag.DagCyclicDependencyException; +import software.amazon.lambda.durable.dag.DagDuplicateTaskException; +import software.amazon.lambda.durable.dag.DagInvalidDependencyException; +import software.amazon.lambda.durable.dag.DagInvalidTaskNameException; + +class DagValidatorTest { + + private static TaskHandleImpl task(String name) { + return new TaskHandleImpl<>(name, TaskKind.STEP, null, null); + } + + @Test + void acyclicGraphPasses() { + var a = task("a"); + var b = task("b"); + var c = task("c").reads(a, b); + assertDoesNotThrow(() -> DagValidator.validate(List.of(a, b, (TaskHandleImpl) c))); + } + + @Test + void diamondIsNotACycle() { + var a = task("a"); + var b = task("b").after(a); + var c = task("c").after(a); + var d = task("d").after(b, c); + assertDoesNotThrow(() -> DagValidator.validate( + List.of(a, (TaskHandleImpl) b, (TaskHandleImpl) c, (TaskHandleImpl) d))); + } + + @Test + void selfLoopIsCycle() { + var a = task("a"); + a.after(a); + assertThrows(DagCyclicDependencyException.class, () -> DagValidator.validate(List.of(a))); + } + + @Test + void twoCycleDetected() { + var a = task("a"); + var b = task("b"); + a.after(b); + b.after(a); + assertThrows(DagCyclicDependencyException.class, () -> DagValidator.validate(List.of(a, b))); + } + + @Test + void deepCycleDetected() { + var a = task("a"); + var b = task("b").after(a); + var c = task("c").after(b); + a.after(c); + assertThrows( + DagCyclicDependencyException.class, + () -> DagValidator.validate(List.of(a, (TaskHandleImpl) b, (TaskHandleImpl) c))); + } + + @Test + void badNameRejected() { + assertThrows(DagInvalidTaskNameException.class, () -> DagValidator.validate(List.of(task("has-dash")))); + assertThrows(DagInvalidTaskNameException.class, () -> DagValidator.validate(List.of(task("")))); + assertThrows(DagInvalidTaskNameException.class, () -> DagValidator.validate(List.of(task("x".repeat(101))))); + assertThrows(DagInvalidTaskNameException.class, () -> DagValidator.validate(List.of(task("my_DAG_NODE_T_x")))); + } + + @Test + void duplicateNamesRejected() { + assertThrows(DagDuplicateTaskException.class, () -> DagValidator.validate(List.of(task("dup"), task("dup")))); + } + + @Test + void foreignDependencyRejected() { + var registered = task("a"); + var foreign = task("foreign"); + var b = task("b").after(foreign); + assertThrows( + DagInvalidDependencyException.class, + () -> DagValidator.validate(List.of(registered, (TaskHandleImpl) b))); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java new file mode 100644 index 000000000..0a43a7256 --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.dag.Deps; +import software.amazon.lambda.durable.dag.TaskExecution; +import software.amazon.lambda.durable.dag.TaskStatus; +import software.amazon.lambda.durable.dag.TriggerRule; + +class TaskHandleTest { + + private static TaskHandleImpl handle(String name) { + return new TaskHandleImpl<>(name, TaskKind.STEP, null, null); + } + + @Test + void buildersMutateHandleState() { + var a = handle("a"); + var b = handle("b"); + var c = handle("c"); + c.reads(a).after(b).triggerRule(TriggerRule.ALL_DONE).runIf(deps -> true); + + assertEquals(1, c.inlineDeps().size()); + assertTrue(c.inlineDeps().contains(a)); + assertEquals(2, c.allDeps().size()); // inline (a) + ordering-only (b) + assertTrue(c.allDeps().contains(a)); + assertTrue(c.allDeps().contains(b)); + assertEquals(Optional.of(TriggerRule.ALL_DONE), c.triggerRuleOpt()); + assertTrue(c.runIfOpt().isPresent()); + } + + @Test + void depsGetReturnsTypedResultForDeclaredInlineDep() { + var a = handle("a"); + var b = handle("b"); + b.reads(a); + + Map> results = new LinkedHashMap<>(); + results.put( + "a", + new TaskExecution<>( + "a", + TaskStatus.SUCCEEDED, + Optional.empty(), + Optional.of("hello"), + Optional.empty(), + Optional.empty(), + Optional.empty())); + + Deps deps = new DepsImpl("b", b.inlineDeps(), results); + assertEquals(Optional.of("hello"), deps.get(a)); + } + + @Test + void depsGetOnUndeclaredHandleThrows() { + var a = handle("a"); + var b = handle("b"); // b does NOT read a + Deps deps = new DepsImpl("b", b.inlineDeps(), new LinkedHashMap<>()); + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> deps.get(a)); + // The message must name both the offending task and the handle, and + // point at .reads(...) on the OFFENDING task's own registration -- + // not on the dependency's -- since that is the one-line fix. + assertTrue(ex.getMessage().contains("'b'")); + assertTrue(ex.getMessage().contains("'a'")); + assertTrue(ex.getMessage().contains(".reads(")); + } + + @Test + void depsGetReturnsEmptyForNonSucceededUpstream() { + var a = handle("a"); + var b = handle("b"); + b.reads(a); + Map> results = new LinkedHashMap<>(); + results.put( + "a", + new TaskExecution<>( + "a", + TaskStatus.FAILED, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty())); + Deps deps = new DepsImpl("b", b.inlineDeps(), results); + assertTrue(deps.get(a).isEmpty()); + } + + /** + * Models the compensation-task scenario the {@code Optional} return type exists for: a downstream task with a + * non-ALL_SUCCESS trigger rule (here {@code ALL_DONE}) runs even though its inline dependency FAILED. Reading that + * dependency's result via {@code deps.get(handle)} must yield {@link Optional#empty()}, not a null masquerading as + * a present value. + */ + @Test + void depsGetReturnsEmptyForFailedUpstreamUnderAllDoneTriggerRule() { + var upstream = handle("upstream"); // designed to fail + var compensate = handle("compensate"); + compensate.reads(upstream).triggerRule(TriggerRule.ALL_DONE); + + // Snapshot the scheduler would hand to the compensation task: upstream terminal but FAILED. + Map> results = new LinkedHashMap<>(); + results.put( + "upstream", + new TaskExecution<>( + "upstream", + TaskStatus.FAILED, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty())); + + Deps deps = new DepsImpl("compensate", compensate.inlineDeps(), results); + + Optional result = deps.get(upstream); + assertTrue(result.isEmpty(), "failed upstream under ALL_DONE must surface as Optional.empty()"); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java new file mode 100644 index 000000000..4c1c996bf --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static software.amazon.lambda.durable.dag.TaskStatus.FAILED; +import static software.amazon.lambda.durable.dag.TaskStatus.SKIPPED; +import static software.amazon.lambda.durable.dag.TaskStatus.SUCCEEDED; + +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.dag.TriggerRule; + +/** Tests the internal trigger-rule evaluator (logic moved off the public {@link TriggerRule} value enum). */ +class TriggerRuleEvaluatorTest { + + @Test + void emptyUpstreamVacuousCases() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ALL_SUCCESS, List.of())); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ALL_FAILED, List.of())); + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ALL_DONE, List.of())); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ANY_SUCCESS, List.of())); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ANY_FAILED, List.of())); + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.NONE_FAILED, List.of())); + } + + @Test + void allSuccess() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ALL_SUCCESS, List.of(SUCCEEDED, SUCCEEDED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ALL_SUCCESS, List.of(SUCCEEDED, FAILED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ALL_SUCCESS, List.of(SUCCEEDED, SKIPPED))); + } + + @Test + void allFailed() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ALL_FAILED, List.of(FAILED, FAILED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ALL_FAILED, List.of(FAILED, SUCCEEDED))); + } + + @Test + void allDone() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ALL_DONE, List.of(SUCCEEDED, FAILED, SKIPPED))); + } + + @Test + void anySuccess() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ANY_SUCCESS, List.of(FAILED, SUCCEEDED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ANY_SUCCESS, List.of(FAILED, SKIPPED))); + } + + @Test + void anyFailed() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.ANY_FAILED, List.of(SUCCEEDED, FAILED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.ANY_FAILED, List.of(SUCCEEDED, SKIPPED))); + } + + @Test + void noneFailed() { + assertEquals(true, TriggerRuleEvaluator.eval(TriggerRule.NONE_FAILED, List.of(SUCCEEDED, SKIPPED))); + assertEquals(false, TriggerRuleEvaluator.eval(TriggerRule.NONE_FAILED, List.of(SUCCEEDED, FAILED))); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/execution/DurableExecutionTest.java b/sdk/src/test/java/software/amazon/lambda/durable/execution/DurableExecutionTest.java index 5b7ccbb6f..337510bd2 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/execution/DurableExecutionTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/execution/DurableExecutionTest.java @@ -22,7 +22,9 @@ import software.amazon.awssdk.services.lambda.model.StepDetails; import software.amazon.lambda.durable.DurableConfig; import software.amazon.lambda.durable.TestUtils; +import software.amazon.lambda.durable.dag.DagPredicateException; import software.amazon.lambda.durable.exception.UnrecoverableDurableExecutionException; +import software.amazon.lambda.durable.exception.WaitForConditionFailedException; import software.amazon.lambda.durable.model.DurableExecutionInput; import software.amazon.lambda.durable.model.ExecutionStatus; import software.amazon.lambda.durable.model.OperationSubType; @@ -137,6 +139,76 @@ void testExecuteFailure() { assertEquals("Test error", output.error().errorMessage()); } + @Test + void testExecuteFailureWithUncaughtDagPredicateException() { + // DAG exceptions are extension-level failures, so the standard non-operation error path must preserve the + // concrete type and message in the execution error. + var executionOp = Operation.builder() + .id(EXECUTION_OP_ID) + .type(OperationType.EXECUTION) + .status(OperationStatus.STARTED) + .executionDetails(ExecutionDetails.builder() + .inputPayload("\"test-input\"") + .build()) + .build(); + + var input = new DurableExecutionInput( + EXECUTION_ARN, + "token1", + CheckpointUpdatedExecutionState.builder() + .operations(List.of(executionOp)) + .build()); + + var output = DurableExecutor.execute( + input, + null, + get(String.class), + (userInput, ctx) -> { + throw new DagPredicateException("guarded", new IllegalStateException("predicate boom")); + }, + configWithMockClient()); + + assertEquals(ExecutionStatus.FAILED, output.status()); + assertNotNull(output.error(), "ExecutionFailed must carry a populated error object for a DAG-family exception"); + assertEquals( + "software.amazon.lambda.durable.dag.DagPredicateException", + output.error().errorType()); + assertTrue(output.error().errorMessage().contains("guarded")); + } + + @Test + void testExecuteFailureWithNonDagNullOperationExceptionKeepsPriorBehaviour() { + // A DurableOperationException with no backing operation or error object keeps the existing null-error + // behavior. Extension exceptions do not use this hierarchy. + var executionOp = Operation.builder() + .id(EXECUTION_OP_ID) + .type(OperationType.EXECUTION) + .status(OperationStatus.STARTED) + .executionDetails(ExecutionDetails.builder() + .inputPayload("\"test-input\"") + .build()) + .build(); + + var input = new DurableExecutionInput( + EXECUTION_ARN, + "token1", + CheckpointUpdatedExecutionState.builder() + .operations(List.of(executionOp)) + .build()); + + var output = DurableExecutor.execute( + input, + null, + get(String.class), + (userInput, ctx) -> { + throw new WaitForConditionFailedException("condition failed"); + }, + configWithMockClient()); + + assertEquals(ExecutionStatus.FAILED, output.status()); + assertNull(output.error(), "non-DAG null-operation exception must keep prior null execution error behaviour"); + } + @Test void testRetryableExceptions() { var executionOp = Operation.builder() diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableMapOperationImplementationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableMapOperationImplementationTest.java index ccf842ceb..13e676b68 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableMapOperationImplementationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableMapOperationImplementationTest.java @@ -6,10 +6,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static software.amazon.lambda.durable.model.OperationSubType.MAP; @@ -25,6 +27,8 @@ import software.amazon.lambda.durable.DurableContext; import software.amazon.lambda.durable.DurableFuture; import software.amazon.lambda.durable.TypeToken; +import software.amazon.lambda.durable.config.CompletionConfig; +import software.amazon.lambda.durable.config.MapConfig; import software.amazon.lambda.durable.context.BaseContextImpl; import software.amazon.lambda.durable.exception.MapIterationFailedException; import software.amazon.lambda.durable.extension.ExtensionContext; @@ -37,6 +41,74 @@ import software.amazon.lambda.durable.serde.JacksonSerDes; class DurableMapOperationImplementationTest { + @Test + void invalidItemNameDoesNotReserveParent() { + var context = mock(ExtensionContext.class); + var config = MapConfig.builder() + .serDes(new JacksonSerDes()) + .itemNamer((item, index) -> "") + .build(); + + assertThrows( + IllegalArgumentException.class, + () -> DurableMapOperation.mapAsync( + context, + "map", + List.of("a"), + TypeToken.get(String.class), + (item, index, child) -> item, + config.toOperationConfig())); + + verify(context, never()).reserve(any()); + } + + @Test + void invalidMinSuccessfulDoesNotReserveParent() { + var context = mock(ExtensionContext.class); + var config = MapConfig.builder() + .serDes(new JacksonSerDes()) + .completionConfig(CompletionConfig.minSuccessful(2)) + .build(); + + assertThrows( + IllegalArgumentException.class, + () -> DurableMapOperation.mapAsync( + context, + "map", + List.of("a"), + TypeToken.get(String.class), + (item, index, child) -> item, + config.toOperationConfig())); + + verify(context, never()).reserve(any()); + } + + @Test + void executeUsesProvidedParentReservation() { + var context = mock(ExtensionContext.class); + var parent = mock(ExtensionOperation.class); + var parentFuture = mockMapFuture(); + var config = MapConfig.builder().serDes(new JacksonSerDes()).build(); + when(parent.runInChildContextAsync( + eq(MAP.getValue()), + any(TypeToken.class), + any(ExtensionContextFunction.class), + any(ExtensionContextConfig.class))) + .thenReturn(parentFuture); + + var actual = DurableMapOperation.mapAsync( + context, + parent, + "map", + List.of("a"), + TypeToken.get(String.class), + (item, index, child) -> item, + config.toOperationConfig()); + + assertSame(parentFuture, actual); + verify(context, never()).reserve(any()); + } + @Test void executeBuildsMapAndIterationContextsFromReservations() { var context = mock(ExtensionContext.class); diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableParallelOperationImplementationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableParallelOperationImplementationTest.java index 59f6a2b1b..9f717ca3e 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableParallelOperationImplementationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableParallelOperationImplementationTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -42,6 +43,26 @@ import software.amazon.lambda.durable.serde.JacksonSerDes; class DurableParallelOperationImplementationTest { + @Test + void executeUsesProvidedParentReservation() { + var context = mock(ExtensionContext.class); + var parent = mock(ExtensionOperation.class); + var parentFuture = mockParallelResultFuture(); + when(context.getDurableConfig()).thenReturn(DurableConfig.builder().build()); + when(parent.runInChildContextAsync( + eq(PARALLEL.getValue()), + any(TypeToken.class), + any(ExtensionContextFunction.class), + any(ExtensionContextConfig.class))) + .thenReturn(parentFuture); + + var actual = DurableParallelOperation.parallel( + context, parent, ParallelConfig.builder().build()); + + assertNotNull(actual); + verify(context, never()).reserve(any()); + } + @Test void executeBuildsParallelAndBranchContextsFromReservations() { var context = mock(ExtensionContext.class); diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperationImplementationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperationImplementationTest.java index 9bf4588a0..8d0ffd0e9 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperationImplementationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperationImplementationTest.java @@ -47,6 +47,34 @@ void clearContext() { BaseContextImpl.setCurrentContext(null); } + @Test + void executeUsesProvidedReservation() { + var reservation = mock(ExtensionOperation.class); + var future = mockStringFuture(); + var resultType = TypeToken.get(String.class); + var config = WaitForConditionConfig.builder().build(); + when(reservation.stepAsync( + eq(OperationSubType.WAIT_FOR_CONDITION.getValue()), + eq(resultType), + any(ExtensionStepFunction.class), + any(ExtensionStepConfig.class))) + .thenReturn(future); + + var actual = DurableWaitForConditionOperation.waitForConditionAsync( + reservation, + resultType, + (state, step) -> WaitForConditionResult.stopPolling("done"), + config.toOperationConfig()); + + assertEquals("done", actual.get()); + verify(reservation) + .stepAsync( + eq(OperationSubType.WAIT_FOR_CONDITION.getValue()), + eq(resultType), + any(ExtensionStepFunction.class), + any(ExtensionStepConfig.class)); + } + @Test void executeMapsPollingResultsToStatefulStepOutcomes() { var context = mock(ExtensionContext.class); From 0a28e840eaae4fe1966606df3c3da8d6f1db8d4e Mon Sep 17 00:00:00 2001 From: Frank Chen <65260095+zhongkechen@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:02:51 -0700 Subject: [PATCH 2/3] refactor(dag): align with durable operation facade style --- .../src/main/java/dag/DagCallback.java | 2 +- .../src/main/java/dag/DagChild.java | 2 +- .../src/main/java/dag/DagCompensate.java | 2 +- .../src/main/java/dag/DagCompensation.java | 2 +- .../main/java/dag/DagConcurrentOverlap.java | 2 +- .../main/java/dag/DagConcurrentSuspend.java | 2 +- .../src/main/java/dag/DagDiamond.java | 2 +- .../src/main/java/dag/DagIdStability.java | 2 +- .../src/main/java/dag/DagInvoke.java | 2 +- .../src/main/java/dag/DagLargePayload.java | 2 +- .../src/main/java/dag/DagMap.java | 2 +- .../src/main/java/dag/DagNested.java | 2 +- .../main/java/dag/DagNestedLargePayload.java | 2 +- .../src/main/java/dag/DagParallel.java | 2 +- .../src/main/java/dag/DagRetry.java | 2 +- .../src/main/java/dag/DagRulesEngine.java | 2 +- .../src/main/java/dag/DagRunIf.java | 2 +- .../src/main/java/dag/DagRunIfAbort.java | 2 +- .../main/java/dag/DagWaitForCondition.java | 2 +- .../src/main/java/dag/DagWaitResume.java | 2 +- docs/DAG_STATUS_JAVA.md | 7 +- docs/core/dag.md | 8 +- .../examples/dag/DagCompensationExample.java | 2 +- .../examples/dag/DagDiamondExample.java | 2 +- .../durable/examples/dag/DagRunIfExample.java | 2 +- .../examples/dag/DagWaitResumeExample.java | 2 +- .../lambda/durable/DagConformanceTest.java | 10 +-- .../lambda/durable/DagIntegrationTest.java | 2 +- .../durable/dag/internal/DagContextImpl.java | 20 ++++- .../internal/ReservedOperationContext.java | 83 +++++++++++++++++++ .../DurableDagOperation.java} | 11 ++- .../operation/DurableMapOperation.java | 49 +---------- .../operation/DurableParallelOperation.java | 21 +---- .../DurableWaitForConditionOperation.java | 37 ++------- .../ReservedOperationContextTest.java | 49 +++++++++++ .../DurableDagOperationTest.java} | 9 +- ...DurableMapOperationImplementationTest.java | 72 ---------------- ...leParallelOperationImplementationTest.java | 21 ----- ...rConditionOperationImplementationTest.java | 28 ------- 39 files changed, 211 insertions(+), 264 deletions(-) create mode 100644 sdk/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java rename sdk/src/main/java/software/amazon/lambda/durable/{dag/DagOperations.java => operation/DurableDagOperation.java} (82%) create mode 100644 sdk/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java rename sdk/src/test/java/software/amazon/lambda/durable/{dag/DagOperationsTest.java => operation/DurableDagOperationTest.java} (95%) diff --git a/conformance-tests/src/main/java/dag/DagCallback.java b/conformance-tests/src/main/java/dag/DagCallback.java index 85dd5ef28..98431d1d8 100644 --- a/conformance-tests/src/main/java/dag/DagCallback.java +++ b/conformance-tests/src/main/java/dag/DagCallback.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagChild.java b/conformance-tests/src/main/java/dag/DagChild.java index a878c63b1..2dcbcae12 100644 --- a/conformance-tests/src/main/java/dag/DagChild.java +++ b/conformance-tests/src/main/java/dag/DagChild.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagCompensate.java b/conformance-tests/src/main/java/dag/DagCompensate.java index 11a1a5bde..37ccc9578 100644 --- a/conformance-tests/src/main/java/dag/DagCompensate.java +++ b/conformance-tests/src/main/java/dag/DagCompensate.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.time.Duration; import java.util.LinkedHashMap; diff --git a/conformance-tests/src/main/java/dag/DagCompensation.java b/conformance-tests/src/main/java/dag/DagCompensation.java index d7378efc0..76eab9fdd 100644 --- a/conformance-tests/src/main/java/dag/DagCompensation.java +++ b/conformance-tests/src/main/java/dag/DagCompensation.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagConcurrentOverlap.java b/conformance-tests/src/main/java/dag/DagConcurrentOverlap.java index e5b93254b..c5b2dc627 100644 --- a/conformance-tests/src/main/java/dag/DagConcurrentOverlap.java +++ b/conformance-tests/src/main/java/dag/DagConcurrentOverlap.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagConcurrentSuspend.java b/conformance-tests/src/main/java/dag/DagConcurrentSuspend.java index 7dd9858e3..12cbef58d 100644 --- a/conformance-tests/src/main/java/dag/DagConcurrentSuspend.java +++ b/conformance-tests/src/main/java/dag/DagConcurrentSuspend.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.time.Duration; import java.util.LinkedHashMap; diff --git a/conformance-tests/src/main/java/dag/DagDiamond.java b/conformance-tests/src/main/java/dag/DagDiamond.java index 12ff26dd5..39ae21b97 100644 --- a/conformance-tests/src/main/java/dag/DagDiamond.java +++ b/conformance-tests/src/main/java/dag/DagDiamond.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagIdStability.java b/conformance-tests/src/main/java/dag/DagIdStability.java index c9cee4e9a..aca1b877e 100644 --- a/conformance-tests/src/main/java/dag/DagIdStability.java +++ b/conformance-tests/src/main/java/dag/DagIdStability.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagInvoke.java b/conformance-tests/src/main/java/dag/DagInvoke.java index e36fdcfe6..28dcc8eb4 100644 --- a/conformance-tests/src/main/java/dag/DagInvoke.java +++ b/conformance-tests/src/main/java/dag/DagInvoke.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagLargePayload.java b/conformance-tests/src/main/java/dag/DagLargePayload.java index e10e47996..a1080f2e5 100644 --- a/conformance-tests/src/main/java/dag/DagLargePayload.java +++ b/conformance-tests/src/main/java/dag/DagLargePayload.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.time.Duration; import java.util.LinkedHashMap; diff --git a/conformance-tests/src/main/java/dag/DagMap.java b/conformance-tests/src/main/java/dag/DagMap.java index 5c55d9798..35f96df68 100644 --- a/conformance-tests/src/main/java/dag/DagMap.java +++ b/conformance-tests/src/main/java/dag/DagMap.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.List; diff --git a/conformance-tests/src/main/java/dag/DagNested.java b/conformance-tests/src/main/java/dag/DagNested.java index b168a86ab..366f16240 100644 --- a/conformance-tests/src/main/java/dag/DagNested.java +++ b/conformance-tests/src/main/java/dag/DagNested.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagNestedLargePayload.java b/conformance-tests/src/main/java/dag/DagNestedLargePayload.java index 8f6456063..7d52515ba 100644 --- a/conformance-tests/src/main/java/dag/DagNestedLargePayload.java +++ b/conformance-tests/src/main/java/dag/DagNestedLargePayload.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.time.Duration; import java.util.LinkedHashMap; diff --git a/conformance-tests/src/main/java/dag/DagParallel.java b/conformance-tests/src/main/java/dag/DagParallel.java index 244dbf074..72aea25aa 100644 --- a/conformance-tests/src/main/java/dag/DagParallel.java +++ b/conformance-tests/src/main/java/dag/DagParallel.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagRetry.java b/conformance-tests/src/main/java/dag/DagRetry.java index efb106609..d24d1e52f 100644 --- a/conformance-tests/src/main/java/dag/DagRetry.java +++ b/conformance-tests/src/main/java/dag/DagRetry.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.time.Duration; import java.util.LinkedHashMap; diff --git a/conformance-tests/src/main/java/dag/DagRulesEngine.java b/conformance-tests/src/main/java/dag/DagRulesEngine.java index ff6ad60eb..176ccd375 100644 --- a/conformance-tests/src/main/java/dag/DagRulesEngine.java +++ b/conformance-tests/src/main/java/dag/DagRulesEngine.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagRunIf.java b/conformance-tests/src/main/java/dag/DagRunIf.java index 1bf104539..6572c7de5 100644 --- a/conformance-tests/src/main/java/dag/DagRunIf.java +++ b/conformance-tests/src/main/java/dag/DagRunIf.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagRunIfAbort.java b/conformance-tests/src/main/java/dag/DagRunIfAbort.java index a3eb422f0..88f1b4ee4 100644 --- a/conformance-tests/src/main/java/dag/DagRunIfAbort.java +++ b/conformance-tests/src/main/java/dag/DagRunIfAbort.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagWaitForCondition.java b/conformance-tests/src/main/java/dag/DagWaitForCondition.java index 928ba534d..90e6bd4be 100644 --- a/conformance-tests/src/main/java/dag/DagWaitForCondition.java +++ b/conformance-tests/src/main/java/dag/DagWaitForCondition.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.util.LinkedHashMap; import java.util.Map; diff --git a/conformance-tests/src/main/java/dag/DagWaitResume.java b/conformance-tests/src/main/java/dag/DagWaitResume.java index b7fe4a2aa..05149f060 100644 --- a/conformance-tests/src/main/java/dag/DagWaitResume.java +++ b/conformance-tests/src/main/java/dag/DagWaitResume.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.time.Duration; import java.util.LinkedHashMap; diff --git a/docs/DAG_STATUS_JAVA.md b/docs/DAG_STATUS_JAVA.md index e167b72e6..499830ee9 100644 --- a/docs/DAG_STATUS_JAVA.md +++ b/docs/DAG_STATUS_JAVA.md @@ -1,18 +1,15 @@ # 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. +- Public entry points are static `DurableDagOperation.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. +- DAG adapts its pre-reserved nodes to the unchanged map, parallel, and wait-for-condition operation facades. - `DagException` extends `DurableExecutionException` directly. - Large results use `ExtensionContextResult.replayChildrenAboveSize`. - DAG scheduler code does not depend on `context`, `execution`, or `primitive` implementation packages. diff --git a/docs/core/dag.md b/docs/core/dag.md index cbfa7565c..227eb2f61 100644 --- a/docs/core/dag.md +++ b/docs/core/dag.md @@ -1,11 +1,11 @@ -# DAG (`DagOperations.dag()`) — ⚠️ EXPERIMENTAL +# DAG (`DurableDagOperation.dag()`) — ⚠️ EXPERIMENTAL > **⚠️ EXPERIMENTAL.** DAG support is an experimental feature and may be changed or removed in future releases > **without a major-version bump**. Every public DAG type/method is annotated with > `@software.amazon.lambda.durable.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 +`DurableDagOperation.dag(...)` declares and runs a **directed acyclic graph of tasks** with typed dependencies. You describe the graph once in a declarative registration phase; the runtime schedules tasks topologically, runs independent chains concurrently via `DurableFuture`, evaluates per-task trigger rules and `runIf` predicates, and aggregates results into a `DagResult`. @@ -19,7 +19,7 @@ that local ID, so graph traversal order can change without changing task operati ## Entry points ```java -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; DagResult dag(String name, Consumer register); DagResult dag(String name, Consumer register, DagConfig config); @@ -27,7 +27,7 @@ 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 +These are static methods on `DurableDagOperation` and must be called from a durable context thread. `register` only *declares* tasks; nothing executes until it returns and the graph is validated. ## Declaring tasks and dependencies 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 index 90b295471..636c34722 100644 --- 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 @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.examples.dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import software.amazon.lambda.durable.DurableContext; import software.amazon.lambda.durable.DurableHandler; 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 index 3fc110360..c27f14964 100644 --- 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 @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.examples.dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import software.amazon.lambda.durable.DurableContext; import software.amazon.lambda.durable.DurableHandler; 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 index 2858ed122..43390deae 100644 --- 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 @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.examples.dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import software.amazon.lambda.durable.DurableContext; import software.amazon.lambda.durable.DurableHandler; 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 index 934d516c9..63edab8f6 100644 --- 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 @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.examples.dag; -import static software.amazon.lambda.durable.dag.DagOperations.dag; +import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.time.Duration; import software.amazon.lambda.durable.DurableContext; 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 index a169a70a5..8982435f0 100644 --- 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 @@ -6,7 +6,7 @@ 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 static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -469,8 +469,8 @@ void dag10_emptyDag() { } // ── 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. + // DurableDagOperation 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() { @@ -812,8 +812,8 @@ private static void assertCounts(DagResult r, int success, int failure, int skip } /** - * 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. + * Asserts the shipped {@link DagValidator} (the validation used by {@code DurableDagOperation}) raises the expected + * typed {@link DagException} for a graph, and records the normalized error token. */ private void assertValidation( String scenario, 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 index 7c066032c..2925d87bd 100644 --- 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 @@ -6,7 +6,7 @@ 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 static software.amazon.lambda.durable.operation.DurableDagOperation.dag; import org.junit.jupiter.api.Test; import software.amazon.awssdk.services.lambda.model.OperationStatus; diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java index 21886e1f7..d88a8a3d3 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java @@ -308,7 +308,15 @@ public TaskHandle waitForCondition( var typeToken = TypeToken.get(type); recordResultType(name, typeToken); TaskExecutor exec = (ctx, operation, deps) -> DurableWaitForConditionOperation.waitForConditionAsync( - operation, typeToken, (state, step) -> check.apply(deps, state, step), config.toOperationConfig()); + new ReservedOperationContext(ctx, name, operation), + name, + typeToken, + (state, step) -> { + var result = check.apply(deps, state, step); + return new DurableWaitForConditionOperation.WaitForConditionResult<>( + result.value(), result.isDone()); + }, + config.toOperationConfig()); return register(new TaskHandleImpl<>(name, TaskKind.WAIT_FOR_CONDITION, exec, config)); } @@ -353,7 +361,12 @@ public TaskHandle> map( String name, Function> items, Class type, MapFunction fn, MapConfig config) { var typeToken = TypeToken.get(type); TaskExecutor> exec = (ctx, operation, deps) -> DurableMapOperation.mapAsync( - ctx, operation, name, items.apply(deps), typeToken, fn, config.toOperationConfig()); + new ReservedOperationContext(ctx, name, operation), + name, + items.apply(deps), + typeToken, + fn, + config.toOperationConfig()); return register(new TaskHandleImpl<>(name, TaskKind.MAP, exec, config)); } @@ -368,7 +381,8 @@ public TaskHandle parallel( String name, Consumer branches, ParallelConfig config) { recordResultType(name, TypeToken.get(ParallelResult.class)); TaskExecutor exec = (ctx, operation, deps) -> { - ParallelDurableFuture p = DurableParallelOperation.parallel(ctx, operation, config.toOperationConfig()); + ParallelDurableFuture p = DurableParallelOperation.parallel( + new ReservedOperationContext(ctx, name, operation), name, config.toOperationConfig()); branches.accept(p); return p; }; diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java b/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java new file mode 100644 index 000000000..e911b9d2a --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import com.amazonaws.services.lambda.runtime.Context; +import java.util.Objects; +import org.slf4j.Logger; +import software.amazon.lambda.durable.DurableConfig; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.extension.ExtensionOperation; +import software.amazon.lambda.durable.logging.DurableLogger; + +/** Supplies one pre-reserved DAG operation to an existing durable operation facade. */ +final class ReservedOperationContext implements ExtensionContext { + private final ExtensionContext delegate; + private final String operationName; + private final ExtensionOperation operation; + private boolean reserved; + + ReservedOperationContext(ExtensionContext delegate, String operationName, ExtensionOperation operation) { + this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null"); + this.operationName = Objects.requireNonNull(operationName, "operationName cannot be null"); + this.operation = Objects.requireNonNull(operation, "operation cannot be null"); + } + + @Override + public boolean isReplaying() { + return delegate.isReplaying(); + } + + @Override + public synchronized ExtensionOperation reserve(String name) { + if (!reserved) { + if (!operationName.equals(name)) { + throw new IllegalStateException( + "Expected operation '" + operationName + "' to be reserved before '" + name + "'"); + } + reserved = true; + return operation; + } + return delegate.reserve(name); + } + + @Override + public ExtensionOperation reserve(String name, String localOperationId) { + return delegate.reserve(name, localOperationId); + } + + @Override + public DurableLogger getLogger() { + return delegate.getLogger(); + } + + @Override + public DurableLogger getLogger(Logger logger) { + return delegate.getLogger(logger); + } + + @Override + public Context getLambdaContext() { + return delegate.getLambdaContext(); + } + + @Override + public String getExecutionArn() { + return delegate.getExecutionArn(); + } + + @Override + public DurableConfig getDurableConfig() { + return delegate.getDurableConfig(); + } + + @Override + public String getContextId() { + return delegate.getContextId(); + } + + @Override + public String getContextName() { + return delegate.getContextName(); + } +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagOperations.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableDagOperation.java similarity index 82% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagOperations.java rename to sdk/src/main/java/software/amazon/lambda/durable/operation/DurableDagOperation.java index b07a92fae..2c2f5202a 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagOperations.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableDagOperation.java @@ -1,19 +1,22 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.amazon.lambda.durable.dag; +package software.amazon.lambda.durable.operation; import java.util.Objects; import java.util.function.Consumer; import software.amazon.lambda.durable.DurableFuture; import software.amazon.lambda.durable.annotations.Experimental; +import software.amazon.lambda.durable.dag.DagConfig; +import software.amazon.lambda.durable.dag.DagContext; +import software.amazon.lambda.durable.dag.DagResult; import software.amazon.lambda.durable.dag.internal.DagContextImpl; import software.amazon.lambda.durable.extension.ExtensionContext; import software.amazon.lambda.durable.util.ParameterValidator; -/** Static entry points for the experimental DAG extension operation. */ +/** Context-free static facade and canonical implementation of experimental durable DAG operations. */ @Experimental -public final class DagOperations { - private DagOperations() {} +public final class DurableDagOperation { + private DurableDagOperation() {} /** Declares and runs a DAG using the current durable extension context. */ public static DagResult dag(String name, Consumer register) { diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java index c5d00d042..f9a11a136 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableMapOperation.java @@ -25,7 +25,6 @@ import software.amazon.lambda.durable.extension.ExtensionContextConfig; import software.amazon.lambda.durable.extension.ExtensionContextReplayContext; import software.amazon.lambda.durable.extension.ExtensionContextResult; -import software.amazon.lambda.durable.extension.ExtensionOperation; import software.amazon.lambda.durable.model.MapResult; import software.amazon.lambda.durable.model.SafeCloseable; import software.amazon.lambda.durable.serde.SerDes; @@ -90,39 +89,7 @@ public static DurableFuture> mapAsync( Objects.requireNonNull(config, "config cannot be null"); ParameterValidator.validateOperationName(name); ParameterValidator.validateOrderedCollection(items); - var preparedMap = prepareMap(context, name, items, config); - return startMap(context, context.reserve(name), name, preparedMap, resultType, function); - } - - /** - * Creates a map using an operation identity reserved by an extension scheduler. - * - * @param context the active extension context - * @param parent the reserved identity for the map container - * @param name the map operation name - */ - public static DurableFuture> mapAsync( - ExtensionContext context, - ExtensionOperation parent, - String name, - Collection items, - TypeToken resultType, - DurableContext.MapFunction function, - MapConfig config) { - Objects.requireNonNull(context, "context cannot be null"); - Objects.requireNonNull(parent, "parent cannot be null"); - Objects.requireNonNull(items, "items cannot be null"); - Objects.requireNonNull(function, "function cannot be null"); - Objects.requireNonNull(resultType, "resultType cannot be null"); - Objects.requireNonNull(config, "config cannot be null"); - ParameterValidator.validateOperationName(name); - ParameterValidator.validateOrderedCollection(items); - var preparedMap = prepareMap(context, name, items, config); - return startMap(context, parent, name, preparedMap, resultType, function); - } - private static PreparedMap prepareMap( - ExtensionContext context, String name, Collection items, MapConfig config) { if (config.serDes() == null) { config = config.toBuilder() .serDes(context.getDurableConfig().getSerDes()) @@ -130,20 +97,10 @@ private static PreparedMap prepareMap( } var itemList = List.copyOf(items); var iterationNames = resolveIterationNames(name, itemList, config); + var parent = context.reserve(name); validateMinSuccessful(itemList, config); - return new PreparedMap<>(itemList, iterationNames, config); - } - private static DurableFuture> startMap( - ExtensionContext context, - ExtensionOperation parent, - String name, - PreparedMap preparedMap, - TypeToken resultType, - DurableContext.MapFunction function) { - var itemList = preparedMap.items(); - var iterationNames = preparedMap.iterationNames(); - var mapConfig = preparedMap.config(); + var mapConfig = config; var virtualEmptyMap = itemList.isEmpty() && !context.getDurableConfig().shouldCheckpointEmptyMap(); var parentConfig = parentContextConfig(mapConfig.serDes(), virtualEmptyMap).toBuilder() .validateCompletedReplay(true) @@ -404,8 +361,6 @@ private static void restore(MapItemContext previous) { } } - private record PreparedMap(List items, List iterationNames, MapConfig config) {} - /** Configuration for durable MAP operations. */ public static final class MapConfig { private final int maxConcurrency; diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableParallelOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableParallelOperation.java index 6adf28157..865344091 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableParallelOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableParallelOperation.java @@ -21,7 +21,6 @@ import software.amazon.lambda.durable.extension.ExtensionContextConfig; import software.amazon.lambda.durable.extension.ExtensionContextReplayContext; import software.amazon.lambda.durable.extension.ExtensionContextResult; -import software.amazon.lambda.durable.extension.ExtensionOperation; import software.amazon.lambda.durable.model.ParallelResult; import software.amazon.lambda.durable.serde.SerDes; import software.amazon.lambda.durable.util.ParameterValidator; @@ -42,22 +41,7 @@ public static ParallelDurableFuture parallel(ExtensionContext context, String na Objects.requireNonNull(context, "context cannot be null"); Objects.requireNonNull(config, "config cannot be null"); ParameterValidator.validateOperationName(name); - return parallel(context, context.reserve(name), config); - } - - /** - * Creates a parallel operation using an identity reserved by an extension scheduler. - * - * @param context the active extension context - * @param parent the reserved identity for the parallel container - * @param config the parallel configuration - */ - public static ParallelDurableFuture parallel( - ExtensionContext context, ExtensionOperation parent, ParallelConfig config) { - Objects.requireNonNull(context, "context cannot be null"); - Objects.requireNonNull(parent, "parent cannot be null"); - Objects.requireNonNull(config, "config cannot be null"); - return new ParallelOperationFuture(context, parent, config); + return new ParallelOperationFuture(context, name, config); } private static final class ParallelOperationFuture implements ParallelDurableFuture { @@ -71,9 +55,10 @@ private static final class ParallelOperationFuture implements ParallelDurableFut private ParallelResult replayState; private boolean registrationClosed; - ParallelOperationFuture(ExtensionContext context, ExtensionOperation parent, ParallelConfig config) { + ParallelOperationFuture(ExtensionContext context, String name, ParallelConfig config) { this.config = config; defaultSerDes = context.getDurableConfig().getSerDes(); + var parent = context.reserve(name); parentFuture = parent.runInChildContextAsync( PARALLEL.getValue(), parallelResultType(), diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperation.java index 2a1c0fe09..7ed460ec9 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperation.java +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperation.java @@ -12,7 +12,6 @@ import software.amazon.lambda.durable.exception.StepFailedException; import software.amazon.lambda.durable.exception.WaitForConditionFailedException; import software.amazon.lambda.durable.extension.ExtensionContext; -import software.amazon.lambda.durable.extension.ExtensionOperation; import software.amazon.lambda.durable.extension.ExtensionStepConfig; import software.amazon.lambda.durable.extension.ExtensionStepResult; import software.amazon.lambda.durable.model.OperationSubType; @@ -93,34 +92,16 @@ public static DurableFuture waitForConditionAsync( Objects.requireNonNull(checkFunction, "checkFunction cannot be null"); Objects.requireNonNull(config, "config cannot be null"); ParameterValidator.validateOperationName(name); - return waitForConditionAsync(context.reserve(name), resultType, checkFunction, config); - } - /** - * Creates a wait-for-condition operation using an identity reserved by an extension scheduler. - * - * @param operation the reserved step identity - * @param resultType the state and result type - * @param checkFunction the condition function - * @param config the wait-for-condition configuration - */ - public static DurableFuture waitForConditionAsync( - ExtensionOperation operation, - TypeToken resultType, - BiFunction> checkFunction, - WaitForConditionConfig config) { - Objects.requireNonNull(operation, "operation cannot be null"); - Objects.requireNonNull(resultType, "resultType cannot be null"); - Objects.requireNonNull(checkFunction, "checkFunction cannot be null"); - Objects.requireNonNull(config, "config cannot be null"); - var future = operation.stepAsync( - OperationSubType.WAIT_FOR_CONDITION.getValue(), - resultType, - state -> evaluate(state, checkFunction, config), - ExtensionStepConfig.builder() - .initialState(config.initialState()) - .serDes(config.serDes()) - .build()); + var future = context.reserve(name) + .stepAsync( + OperationSubType.WAIT_FOR_CONDITION.getValue(), + resultType, + state -> evaluate(state, checkFunction, config), + ExtensionStepConfig.builder() + .initialState(config.initialState()) + .serDes(config.serDes()) + .build()); return new WaitForConditionFuture<>(future); } diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java new file mode 100644 index 000000000..a14876f3a --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.dag.internal; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.extension.ExtensionContext; +import software.amazon.lambda.durable.extension.ExtensionOperation; + +class ReservedOperationContextTest { + @Test + void suppliesReservedOperationThenDelegates() { + var delegate = mock(ExtensionContext.class); + var reserved = mock(ExtensionOperation.class); + var next = mock(ExtensionOperation.class); + when(delegate.reserve("next")).thenReturn(next); + var context = new ReservedOperationContext(delegate, "node", reserved); + + assertSame(reserved, context.reserve("node")); + assertSame(next, context.reserve("next")); + + verify(delegate).reserve("next"); + } + + @Test + void rejectsUnexpectedFirstReservation() { + var context = + new ReservedOperationContext(mock(ExtensionContext.class), "node", mock(ExtensionOperation.class)); + + assertThrows(IllegalStateException.class, () -> context.reserve("other")); + } + + @Test + void delegatesStableLocalReservations() { + var delegate = mock(ExtensionContext.class); + var local = mock(ExtensionOperation.class); + when(delegate.reserve("child", "stable-id")).thenReturn(local); + var context = new ReservedOperationContext(delegate, "node", mock(ExtensionOperation.class)); + + assertSame(local, context.reserve("child", "stable-id")); + + verify(delegate).reserve("child", "stable-id"); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/DagOperationsTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableDagOperationTest.java similarity index 95% rename from sdk/src/test/java/software/amazon/lambda/durable/dag/DagOperationsTest.java rename to sdk/src/test/java/software/amazon/lambda/durable/operation/DurableDagOperationTest.java index 9171518a5..16c0bd1e2 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/dag/DagOperationsTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableDagOperationTest.java @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.amazon.lambda.durable.dag; +package software.amazon.lambda.durable.operation; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -24,6 +24,7 @@ import software.amazon.lambda.durable.config.StepConfig; import software.amazon.lambda.durable.config.StepSemantics; import software.amazon.lambda.durable.context.BaseContextImpl; +import software.amazon.lambda.durable.dag.DagResult; import software.amazon.lambda.durable.dag.internal.DagResultSerDes; import software.amazon.lambda.durable.extension.ExtensionContext; import software.amazon.lambda.durable.extension.ExtensionContextConfig; @@ -35,7 +36,7 @@ import software.amazon.lambda.durable.retry.RetryDecision; import software.amazon.lambda.durable.serde.JacksonSerDes; -class DagOperationsTest { +class DurableDagOperationTest { @Test void dagUsesExtensionReservationsForContainerAndTasks() { var context = mock(ExtensionContext.class); @@ -60,7 +61,7 @@ void dagUsesExtensionReservationsForContainerAndTasks() { DurableFuture actual; try (var ignored = BaseContextImpl.attachCurrentContext(context)) { - actual = DagOperations.dagAsync( + actual = DurableDagOperation.dagAsync( "graph", dag -> dag.step("node", String.class, (deps, step) -> "done", stepConfig)); } @@ -111,7 +112,7 @@ void invalidNameFailsBeforeRegistration() { try (var ignored = BaseContextImpl.attachCurrentContext(context)) { assertThrows( IllegalArgumentException.class, - () -> DagOperations.dagAsync("", dag -> registrationCalled.set(true))); + () -> DurableDagOperation.dagAsync("", dag -> registrationCalled.set(true))); } assertEquals(false, registrationCalled.get()); diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableMapOperationImplementationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableMapOperationImplementationTest.java index 13e676b68..ccf842ceb 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableMapOperationImplementationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableMapOperationImplementationTest.java @@ -6,12 +6,10 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static software.amazon.lambda.durable.model.OperationSubType.MAP; @@ -27,8 +25,6 @@ import software.amazon.lambda.durable.DurableContext; import software.amazon.lambda.durable.DurableFuture; import software.amazon.lambda.durable.TypeToken; -import software.amazon.lambda.durable.config.CompletionConfig; -import software.amazon.lambda.durable.config.MapConfig; import software.amazon.lambda.durable.context.BaseContextImpl; import software.amazon.lambda.durable.exception.MapIterationFailedException; import software.amazon.lambda.durable.extension.ExtensionContext; @@ -41,74 +37,6 @@ import software.amazon.lambda.durable.serde.JacksonSerDes; class DurableMapOperationImplementationTest { - @Test - void invalidItemNameDoesNotReserveParent() { - var context = mock(ExtensionContext.class); - var config = MapConfig.builder() - .serDes(new JacksonSerDes()) - .itemNamer((item, index) -> "") - .build(); - - assertThrows( - IllegalArgumentException.class, - () -> DurableMapOperation.mapAsync( - context, - "map", - List.of("a"), - TypeToken.get(String.class), - (item, index, child) -> item, - config.toOperationConfig())); - - verify(context, never()).reserve(any()); - } - - @Test - void invalidMinSuccessfulDoesNotReserveParent() { - var context = mock(ExtensionContext.class); - var config = MapConfig.builder() - .serDes(new JacksonSerDes()) - .completionConfig(CompletionConfig.minSuccessful(2)) - .build(); - - assertThrows( - IllegalArgumentException.class, - () -> DurableMapOperation.mapAsync( - context, - "map", - List.of("a"), - TypeToken.get(String.class), - (item, index, child) -> item, - config.toOperationConfig())); - - verify(context, never()).reserve(any()); - } - - @Test - void executeUsesProvidedParentReservation() { - var context = mock(ExtensionContext.class); - var parent = mock(ExtensionOperation.class); - var parentFuture = mockMapFuture(); - var config = MapConfig.builder().serDes(new JacksonSerDes()).build(); - when(parent.runInChildContextAsync( - eq(MAP.getValue()), - any(TypeToken.class), - any(ExtensionContextFunction.class), - any(ExtensionContextConfig.class))) - .thenReturn(parentFuture); - - var actual = DurableMapOperation.mapAsync( - context, - parent, - "map", - List.of("a"), - TypeToken.get(String.class), - (item, index, child) -> item, - config.toOperationConfig()); - - assertSame(parentFuture, actual); - verify(context, never()).reserve(any()); - } - @Test void executeBuildsMapAndIterationContextsFromReservations() { var context = mock(ExtensionContext.class); diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableParallelOperationImplementationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableParallelOperationImplementationTest.java index 9f717ca3e..59f6a2b1b 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableParallelOperationImplementationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableParallelOperationImplementationTest.java @@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -43,26 +42,6 @@ import software.amazon.lambda.durable.serde.JacksonSerDes; class DurableParallelOperationImplementationTest { - @Test - void executeUsesProvidedParentReservation() { - var context = mock(ExtensionContext.class); - var parent = mock(ExtensionOperation.class); - var parentFuture = mockParallelResultFuture(); - when(context.getDurableConfig()).thenReturn(DurableConfig.builder().build()); - when(parent.runInChildContextAsync( - eq(PARALLEL.getValue()), - any(TypeToken.class), - any(ExtensionContextFunction.class), - any(ExtensionContextConfig.class))) - .thenReturn(parentFuture); - - var actual = DurableParallelOperation.parallel( - context, parent, ParallelConfig.builder().build()); - - assertNotNull(actual); - verify(context, never()).reserve(any()); - } - @Test void executeBuildsParallelAndBranchContextsFromReservations() { var context = mock(ExtensionContext.class); diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperationImplementationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperationImplementationTest.java index 8d0ffd0e9..9bf4588a0 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperationImplementationTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableWaitForConditionOperationImplementationTest.java @@ -47,34 +47,6 @@ void clearContext() { BaseContextImpl.setCurrentContext(null); } - @Test - void executeUsesProvidedReservation() { - var reservation = mock(ExtensionOperation.class); - var future = mockStringFuture(); - var resultType = TypeToken.get(String.class); - var config = WaitForConditionConfig.builder().build(); - when(reservation.stepAsync( - eq(OperationSubType.WAIT_FOR_CONDITION.getValue()), - eq(resultType), - any(ExtensionStepFunction.class), - any(ExtensionStepConfig.class))) - .thenReturn(future); - - var actual = DurableWaitForConditionOperation.waitForConditionAsync( - reservation, - resultType, - (state, step) -> WaitForConditionResult.stopPolling("done"), - config.toOperationConfig()); - - assertEquals("done", actual.get()); - verify(reservation) - .stepAsync( - eq(OperationSubType.WAIT_FOR_CONDITION.getValue()), - eq(resultType), - any(ExtensionStepFunction.class), - any(ExtensionStepConfig.class)); - } - @Test void executeMapsPollingResultsToStatefulStepOutcomes() { var context = mock(ExtensionContext.class); From 818b256a1f80bfcdf7853d47f3853ac9a051f5bf Mon Sep 17 00:00:00 2001 From: Frank Chen Date: Tue, 11 Aug 2026 02:12:02 +0000 Subject: [PATCH 3/3] refactor(dag): publish DAG as separate artifact --- .github/scripts/maven_publish.sh | 1 + .github/workflows/build.yml | 2 + .github/workflows/e2e-tests.yml | 2 + .github/workflows/publish_maven.yml | 1 + RELEASE.md | 9 +- .../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 ------- coverage-report/pom.xml | 5 + dag/pom.xml | 106 ++ .../durable/dag/CustomDagCompletion.java | 1 - .../durable/dag/DagCallbackSubmitter.java | 1 - .../lambda/durable/dag/DagChildFunction.java | 1 - .../durable/dag/DagCompletionConfig.java | 1 - .../durable/dag/DagCompletionDecision.java | 2 - .../durable/dag/DagCompletionItemStatus.java | 1 - .../durable/dag/DagCompletionOutcome.java | 2 - .../durable/dag/DagCompletionReason.java | 2 - .../durable/dag/DagCompletionStatus.java | 1 - .../durable/dag/DagConditionFunction.java | 1 - .../amazon/lambda/durable/dag/DagConfig.java | 1 - .../amazon/lambda/durable/dag/DagContext.java | 1 - .../dag/DagCyclicDependencyException.java | 2 - .../dag/DagDuplicateTaskException.java | 2 - .../lambda/durable/dag/DagException.java | 1 - .../durable/dag/DagExecutionException.java | 1 - .../dag/DagInvalidDependencyException.java | 2 - .../dag/DagInvalidTaskNameException.java | 2 - .../durable/dag/DagPayloadFunction.java | 2 - .../durable/dag/DagPredicateException.java | 1 - .../amazon/lambda/durable/dag/DagResult.java | 1 - .../lambda/durable/dag/DagStep1Function.java | 1 - .../lambda/durable/dag/DagStep2Function.java | 1 - .../lambda/durable/dag/DagStep3Function.java | 1 - .../lambda/durable/dag/DagStepFunction.java | 1 - .../lambda/durable/dag/DagTaskError.java | 1 - .../amazon/lambda/durable/dag/Deps.java | 1 - .../durable/dag}/DurableDagOperation.java | 6 +- .../lambda/durable/dag}/Experimental.java | 2 +- .../amazon/lambda/durable/dag/SkipReason.java | 2 - .../lambda/durable/dag/TaskExecution.java | 1 - .../amazon/lambda/durable/dag/TaskHandle.java | 1 - .../amazon/lambda/durable/dag/TaskStatus.java | 2 - .../durable/dag/ThresholdDagCompletion.java | 1 - .../lambda/durable/dag/TriggerRule.java | 2 - .../durable/dag/internal/DagContextImpl.java | 0 .../dag/internal/DagExecutionOutcome.java | 0 .../durable/dag/internal/DagExecutor.java | 0 .../durable/dag/internal/DagResultImpl.java | 0 .../durable/dag/internal/DagResultSerDes.java | 0 .../durable/dag/internal/DagResultTypes.java | 0 .../durable/dag/internal/DagValidator.java | 0 .../lambda/durable/dag/internal/DepsImpl.java | 0 .../internal/ReservedOperationContext.java | 0 .../dag/internal/SerializedDagResult.java | 0 .../dag/internal/SerializedResultKind.java | 0 .../dag/internal/SerializedTaskExecution.java | 0 .../durable/dag/internal/TaskExecutor.java | 0 .../durable/dag/internal/TaskHandleImpl.java | 0 .../lambda/durable/dag/internal/TaskKind.java | 0 .../dag/internal/TriggerRuleEvaluator.java | 0 .../lambda/durable/DagIntegrationTest.java | 2 +- .../dag/DagExecutionExceptionTest.java | 0 .../dag/DagPredicateExceptionTest.java | 0 .../durable/dag}/DurableDagOperationTest.java | 3 +- .../internal/DagEnvelopeConvergenceTest.java | 0 .../durable/dag/internal/DagResultTest.java | 0 .../dag/internal/DagValidatorTest.java | 0 .../ReservedOperationContextTest.java | 0 .../durable/dag/internal/TaskHandleTest.java | 0 .../internal/TriggerRuleEvaluatorTest.java | 0 docs/DAG_STATUS_JAVA.md | 3 + docs/core/dag.md | 16 +- examples/pom.xml | 7 + .../dag/DagCompensationExample.java | 4 +- .../dag/DagDiamondExample.java | 4 +- .../{ => operation}/dag/DagRunIfExample.java | 4 +- .../dag/DagWaitResumeExample.java | 4 +- .../examples/CloudBasedIntegrationTest.java | 65 -- .../operation/dag/DagExamplesTest.java | 52 + pom.xml | 1 + .../lambda/durable/DagConformanceTest.java | 1029 ----------------- .../execution/DurableExecutionTest.java | 72 -- 102 files changed, 211 insertions(+), 2911 deletions(-) delete mode 100644 conformance-tests/src/main/java/dag/DagCallback.java delete mode 100644 conformance-tests/src/main/java/dag/DagChild.java delete mode 100644 conformance-tests/src/main/java/dag/DagCompensate.java delete mode 100644 conformance-tests/src/main/java/dag/DagCompensation.java delete mode 100644 conformance-tests/src/main/java/dag/DagConcurrentOverlap.java delete mode 100644 conformance-tests/src/main/java/dag/DagConcurrentSuspend.java delete mode 100644 conformance-tests/src/main/java/dag/DagDiamond.java delete mode 100644 conformance-tests/src/main/java/dag/DagIdStability.java delete mode 100644 conformance-tests/src/main/java/dag/DagInvoke.java delete mode 100644 conformance-tests/src/main/java/dag/DagLargePayload.java delete mode 100644 conformance-tests/src/main/java/dag/DagMap.java delete mode 100644 conformance-tests/src/main/java/dag/DagNested.java delete mode 100644 conformance-tests/src/main/java/dag/DagNestedLargePayload.java delete mode 100644 conformance-tests/src/main/java/dag/DagParallel.java delete mode 100644 conformance-tests/src/main/java/dag/DagRetry.java delete mode 100644 conformance-tests/src/main/java/dag/DagRulesEngine.java delete mode 100644 conformance-tests/src/main/java/dag/DagRunIf.java delete mode 100644 conformance-tests/src/main/java/dag/DagRunIfAbort.java delete mode 100644 conformance-tests/src/main/java/dag/DagSummary.java delete mode 100644 conformance-tests/src/main/java/dag/DagWaitForCondition.java delete mode 100644 conformance-tests/src/main/java/dag/DagWaitResume.java delete mode 100644 conformance-tests/template_dag.yaml create mode 100644 dag/pom.xml rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java (93%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java (91%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java (91%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java (97%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java (95%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java (93%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java (90%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java (95%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java (94%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java (93%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java (98%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagContext.java (99%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java (89%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java (89%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagException.java (92%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java (96%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java (90%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java (91%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java (88%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java (98%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagResult.java (97%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java (92%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java (93%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java (94%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java (90%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java (97%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/Deps.java (96%) rename {sdk/src/main/java/software/amazon/lambda/durable/operation => dag/src/main/java/software/amazon/lambda/durable/dag}/DurableDagOperation.java (87%) rename {sdk/src/main/java/software/amazon/lambda/durable/annotations => dag/src/main/java/software/amazon/lambda/durable/dag}/Experimental.java (94%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java (90%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java (95%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java (97%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java (92%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java (90%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java (95%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutionOutcome.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutor.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultImpl.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultSerDes.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultTypes.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/DagValidator.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/DepsImpl.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedDagResult.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedResultKind.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedTaskExecution.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/TaskExecutor.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/TaskHandleImpl.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/TaskKind.java (100%) rename {sdk => dag}/src/main/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluator.java (100%) rename {sdk-integration-tests => dag}/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java (99%) rename {sdk => dag}/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java (100%) rename {sdk => dag}/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java (100%) rename {sdk/src/test/java/software/amazon/lambda/durable/operation => dag/src/test/java/software/amazon/lambda/durable/dag}/DurableDagOperationTest.java (98%) rename {sdk => dag}/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java (100%) rename {sdk => dag}/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java (100%) rename {sdk => dag}/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java (100%) rename {sdk => dag}/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java (100%) rename {sdk => dag}/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java (100%) rename {sdk => dag}/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java (100%) rename examples/src/main/java/software/amazon/lambda/durable/examples/{ => operation}/dag/DagCompensationExample.java (94%) rename examples/src/main/java/software/amazon/lambda/durable/examples/{ => operation}/dag/DagDiamondExample.java (90%) rename examples/src/main/java/software/amazon/lambda/durable/examples/{ => operation}/dag/DagRunIfExample.java (90%) rename examples/src/main/java/software/amazon/lambda/durable/examples/{ => operation}/dag/DagWaitResumeExample.java (92%) create mode 100644 examples/src/test/java/software/amazon/lambda/durable/examples/operation/dag/DagExamplesTest.java delete mode 100644 sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagConformanceTest.java diff --git a/.github/scripts/maven_publish.sh b/.github/scripts/maven_publish.sh index 655ad5c69..8e63903a2 100644 --- a/.github/scripts/maven_publish.sh +++ b/.github/scripts/maven_publish.sh @@ -43,6 +43,7 @@ echo "settings.xml written." echo "=== Step 3: Upload to Sonatype Central Portal ===" mvn clean deploy -s "${SETTINGS_FILE}" -pl sdk -P publishing -DskipTests --no-transfer-progress +mvn clean deploy -s "${SETTINGS_FILE}" -pl dag -P publishing -DskipTests --no-transfer-progress mvn clean deploy -s "${SETTINGS_FILE}" -pl sdk-testing -P publishing -DskipTests --no-transfer-progress mvn clean deploy -s "${SETTINGS_FILE}" -pl otel-plugin -P publishing -DskipTests --no-transfer-progress diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b36580103..c03d4bf00 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,7 @@ on: - '.github/workflows/ai-pr-review.yml' - '.github/prompts/ai-pr-review.md' - 'sdk/**' + - 'dag/**' - 'sdk-testing/**' - 'sdk-integration-tests/**' - 'examples/**' @@ -38,6 +39,7 @@ on: - '.github/workflows/ai-pr-review.yml' - '.github/prompts/ai-pr-review.md' - 'sdk/**' + - 'dag/**' - 'sdk-testing/**' - 'sdk-integration-tests/**' - 'examples/**' diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b7ff944ac..717fbfd3e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -8,6 +8,7 @@ on: paths: - '.github/**' # for testing Github Actions - 'sdk/**' + - 'dag/**' - 'sdk-testing/**' - 'sdk-integration-tests/**' - 'examples/**' @@ -18,6 +19,7 @@ on: paths: - '.github/**' - 'sdk/**' + - 'dag/**' - 'sdk-testing/**' - 'sdk-integration-tests/**' - 'examples/**' diff --git a/.github/workflows/publish_maven.yml b/.github/workflows/publish_maven.yml index a664ca282..9d6c16c76 100644 --- a/.github/workflows/publish_maven.yml +++ b/.github/workflows/publish_maven.yml @@ -90,6 +90,7 @@ jobs: run: | gh release upload "$RELEASE_TAG" \ "sdk/target/aws-durable-execution-sdk-java-${RELEASE_VERSION}.jar" \ + "dag/target/aws-durable-execution-sdk-java-dag-${RELEASE_VERSION}.jar" \ "sdk-testing/target/aws-durable-execution-sdk-java-testing-${RELEASE_VERSION}.jar" \ "otel-plugin/target/aws-durable-execution-sdk-java-plugin-otel-${RELEASE_VERSION}.jar" \ --clobber diff --git a/RELEASE.md b/RELEASE.md index 90c347ff1..a7cdbc16b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -43,9 +43,9 @@ The publication workflow: 1. Verifies that the tag is a semantic version, points to a commit on the default branch, and matches the Maven version in the tagged POM. -2. Builds, signs, and uploads the SDK, testing library, and OpenTelemetry plugin - to Sonatype Central Portal. -3. Uploads the three JARs to the existing GitHub release. +2. Builds, signs, and uploads the SDK, DAG extension, testing library, and + OpenTelemetry plugin to Sonatype Central Portal. +3. Uploads the four JARs to the existing GitHub release. 4. Opens a pull request for the next development version. A final release increments the patch version, so `2.1.1` produces `2.1.2-SNAPSHOT`. A prerelease keeps the same base version, so `2.1.1-rc1` produces @@ -56,7 +56,8 @@ After **Publish Maven Release** succeeds: 1. Open [Publishing Deployments](https://central.sonatype.com/publishing/deployments) in Sonatype Central Portal. 2. Find the deployments for the release version and verify that they contain - the expected SDK, testing library, and OpenTelemetry plugin artifacts. + the expected SDK, DAG extension, testing library, and OpenTelemetry plugin + artifacts. 3. Click **Publish** for each deployment and wait for publication to complete. The workflow uses `autoPublish=false`, so this manual action is required. 4. Confirm that the GitHub release contains the expected JARs and that the diff --git a/conformance-tests/src/main/java/dag/DagCallback.java b/conformance-tests/src/main/java/dag/DagCallback.java deleted file mode 100644 index 98431d1d8..000000000 --- a/conformance-tests/src/main/java/dag/DagCallback.java +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 2dcbcae12..000000000 --- a/conformance-tests/src/main/java/dag/DagChild.java +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 37ccc9578..000000000 --- a/conformance-tests/src/main/java/dag/DagCompensate.java +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 76eab9fdd..000000000 --- a/conformance-tests/src/main/java/dag/DagCompensation.java +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index c5b2dc627..000000000 --- a/conformance-tests/src/main/java/dag/DagConcurrentOverlap.java +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 12cbef58d..000000000 --- a/conformance-tests/src/main/java/dag/DagConcurrentSuspend.java +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 39ae21b97..000000000 --- a/conformance-tests/src/main/java/dag/DagDiamond.java +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index aca1b877e..000000000 --- a/conformance-tests/src/main/java/dag/DagIdStability.java +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 28dcc8eb4..000000000 --- a/conformance-tests/src/main/java/dag/DagInvoke.java +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index a1080f2e5..000000000 --- a/conformance-tests/src/main/java/dag/DagLargePayload.java +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 35f96df68..000000000 --- a/conformance-tests/src/main/java/dag/DagMap.java +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 366f16240..000000000 --- a/conformance-tests/src/main/java/dag/DagNested.java +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 7d52515ba..000000000 --- a/conformance-tests/src/main/java/dag/DagNestedLargePayload.java +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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: - * - *

    - *
  1. 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. - *
  2. 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. - *
  3. An outer 2s {@code wait} that ends the invocation, forcing the next one to replay both completed containers. - *
  4. {@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 deleted file mode 100644 index 72aea25aa..000000000 --- a/conformance-tests/src/main/java/dag/DagParallel.java +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index d24d1e52f..000000000 --- a/conformance-tests/src/main/java/dag/DagRetry.java +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 176ccd375..000000000 --- a/conformance-tests/src/main/java/dag/DagRulesEngine.java +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 6572c7de5..000000000 --- a/conformance-tests/src/main/java/dag/DagRunIf.java +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 88f1b4ee4..000000000 --- a/conformance-tests/src/main/java/dag/DagRunIfAbort.java +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index cefa73e44..000000000 --- a/conformance-tests/src/main/java/dag/DagSummary.java +++ /dev/null @@ -1,30 +0,0 @@ -// 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 deleted file mode 100644 index 90e6bd4be..000000000 --- a/conformance-tests/src/main/java/dag/DagWaitForCondition.java +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 05149f060..000000000 --- a/conformance-tests/src/main/java/dag/DagWaitResume.java +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package dag; - -import static software.amazon.lambda.durable.operation.DurableDagOperation.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 deleted file mode 100644 index 04ba292f7..000000000 --- a/conformance-tests/template_dag.yaml +++ /dev/null @@ -1,388 +0,0 @@ -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/coverage-report/pom.xml b/coverage-report/pom.xml index e820594e1..238422e45 100644 --- a/coverage-report/pom.xml +++ b/coverage-report/pom.xml @@ -22,6 +22,11 @@ aws-durable-execution-sdk-java ${project.version} + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-dag + ${project.version} + software.amazon.lambda.durable aws-durable-execution-sdk-java-testing diff --git a/dag/pom.xml b/dag/pom.xml new file mode 100644 index 000000000..6f04aa856 --- /dev/null +++ b/dag/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-parent + 2.1.1-SNAPSHOT + + + aws-durable-execution-sdk-java-dag + jar + + AWS Lambda Durable Execution SDK DAG Extension + Experimental DAG extension for the AWS Lambda Durable Execution SDK + https://github.com/aws/aws-durable-execution-sdk-java + + + scm:git:https://github.com/aws/aws-durable-execution-sdk-java.git + scm:git:https://github.com/aws/aws-durable-execution-sdk-java.git + https://github.com/aws/aws-durable-execution-sdk-java + + + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java + ${project.version} + + + + com.fasterxml.jackson.core + jackson-annotations + + + com.amazonaws + aws-lambda-java-core + + + org.slf4j + slf4j-api + + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-testing + ${project.version} + test + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + + + diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java b/dag/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java similarity index 93% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java index 219189e1f..6a08ff937 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/CustomDagCompletion.java @@ -4,7 +4,6 @@ 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 diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java similarity index 91% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java index fa506179e..0240cea61 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCallbackSubmitter.java @@ -3,7 +3,6 @@ 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 diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java similarity index 91% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java index a970de975..2ee034cf4 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagChildFunction.java @@ -3,7 +3,6 @@ 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 diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java similarity index 97% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java index 0e9a4b6f3..43e5725bf 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionConfig.java @@ -3,7 +3,6 @@ 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; /** diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java similarity index 95% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java index 03295cc0b..6338d4744 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionDecision.java @@ -2,8 +2,6 @@ // 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. * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java similarity index 93% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java index a856f4479..4f7a45796 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionItemStatus.java @@ -3,7 +3,6 @@ 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. diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java similarity index 90% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java index 03c96a3e8..4ebf9d536 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionOutcome.java @@ -2,8 +2,6 @@ // 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. * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java similarity index 95% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java index f03b46ed3..78beb2031 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionReason.java @@ -2,8 +2,6 @@ // 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). diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java similarity index 94% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java index 8861f5b93..978066314 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCompletionStatus.java @@ -4,7 +4,6 @@ import java.util.List; import java.util.Map; -import software.amazon.lambda.durable.annotations.Experimental; /** * Progress snapshot passed to a DAG custom completion predicate. diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java similarity index 93% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java index 782c585ce..92780d94c 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagConditionFunction.java @@ -3,7 +3,6 @@ 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; /** diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java similarity index 98% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java index ba2748cc7..8d1699e96 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagConfig.java @@ -3,7 +3,6 @@ package software.amazon.lambda.durable.dag; import java.util.Optional; -import software.amazon.lambda.durable.annotations.Experimental; import software.amazon.lambda.durable.serde.SerDes; /** diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagContext.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagContext.java similarity index 99% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagContext.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagContext.java index 2e4036502..c094b6117 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagContext.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagContext.java @@ -9,7 +9,6 @@ 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; diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java similarity index 89% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java index b223fd106..7a342a426 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagCyclicDependencyException.java @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.dag; -import software.amazon.lambda.durable.annotations.Experimental; - /** * Thrown at registration when the declared task dependencies form a cycle. * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java similarity index 89% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java index a852d043c..da2b9ad11 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagDuplicateTaskException.java @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.dag; -import software.amazon.lambda.durable.annotations.Experimental; - /** * Thrown at registration when two tasks are registered under the same name in the same DAG scope. * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagException.java similarity index 92% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagException.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagException.java index 9ea743637..c68809aae 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagException.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagException.java @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.dag; -import software.amazon.lambda.durable.annotations.Experimental; import software.amazon.lambda.durable.exception.DurableExecutionException; /** diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java similarity index 96% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java index f0650e702..76f00a735 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagExecutionException.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import software.amazon.lambda.durable.annotations.Experimental; /** * Thrown by {@code DagResult.throwIfError()} when the DAG completed with at least one failed task. Wraps the first diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java similarity index 90% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java index b56c68e17..992d99d0a 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidDependencyException.java @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.dag; -import software.amazon.lambda.durable.annotations.Experimental; - /** * Thrown at registration when a task declares a dependency on a handle that was not registered in the same DAG scope. * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java similarity index 91% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java index c4548dced..065ca1c55 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagInvalidTaskNameException.java @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.dag; -import software.amazon.lambda.durable.annotations.Experimental; - /** * Thrown at registration when a task name violates the DAG charset rules ({@code ^[a-zA-Z0-9_]+$}, {@code <= 100} * chars, must not contain the reserved sequence {@code DAG_NODE_T_}). diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java similarity index 88% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java index c66f61072..0558bceeb 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagPayloadFunction.java @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.dag; -import software.amazon.lambda.durable.annotations.Experimental; - /** * Produces the payload for a DAG invoke task from resolved upstream results ({@link Deps}). * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java similarity index 98% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java index 253c83798..647cf21fd 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagPredicateException.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import software.amazon.lambda.durable.annotations.Experimental; /** * Thrown when a task's {@code runIf} predicate throws. A {@code runIf} predicate is specified as a synchronous, diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagResult.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagResult.java similarity index 97% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagResult.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagResult.java index c16a8678a..4fb829de9 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagResult.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagResult.java @@ -5,7 +5,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import software.amazon.lambda.durable.annotations.Experimental; /** * Aggregate result of a completed DAG. Provides typed and untyped accessors for individual task results and statuses, diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java similarity index 92% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java index 2046687f3..33d788eb8 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep1Function.java @@ -3,7 +3,6 @@ package software.amazon.lambda.durable.dag; import software.amazon.lambda.durable.StepContext; -import software.amazon.lambda.durable.annotations.Experimental; /** * A DAG step body for the positional-arity sugar with a single typed upstream dependency: receives the resolved result diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java similarity index 93% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java index 839b0eef0..41f724f4c 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep2Function.java @@ -3,7 +3,6 @@ package software.amazon.lambda.durable.dag; import software.amazon.lambda.durable.StepContext; -import software.amazon.lambda.durable.annotations.Experimental; /** * A DAG step body for the positional-arity sugar with two typed upstream dependencies: receives the resolved results of diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java similarity index 94% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java index e7d27bb28..4211cf198 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStep3Function.java @@ -3,7 +3,6 @@ package software.amazon.lambda.durable.dag; import software.amazon.lambda.durable.StepContext; -import software.amazon.lambda.durable.annotations.Experimental; /** * A DAG step body for the positional-arity sugar with three typed upstream dependencies: receives the resolved results diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java similarity index 90% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java index 1d305e894..7ddbd0790 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagStepFunction.java @@ -3,7 +3,6 @@ package software.amazon.lambda.durable.dag; import software.amazon.lambda.durable.StepContext; -import software.amazon.lambda.durable.annotations.Experimental; /** * A DAG step task body: receives resolved upstream results ({@link Deps}, empty for roots) and a {@link StepContext}. diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java similarity index 97% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java index 754544bef..11db4a36e 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DagTaskError.java @@ -7,7 +7,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Optional; -import software.amazon.lambda.durable.annotations.Experimental; import software.amazon.lambda.durable.util.ExceptionHelper; /** diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/Deps.java b/dag/src/main/java/software/amazon/lambda/durable/dag/Deps.java similarity index 96% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/Deps.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/Deps.java index 39c21c6b2..462fbcf9a 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/Deps.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/Deps.java @@ -3,7 +3,6 @@ package software.amazon.lambda.durable.dag; import java.util.Optional; -import software.amazon.lambda.durable.annotations.Experimental; /** * Typed accessor for the results of a DAG task's upstream (inline) dependencies. Passed as the first parameter of every diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableDagOperation.java b/dag/src/main/java/software/amazon/lambda/durable/dag/DurableDagOperation.java similarity index 87% rename from sdk/src/main/java/software/amazon/lambda/durable/operation/DurableDagOperation.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/DurableDagOperation.java index 2c2f5202a..d5f27d6e0 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableDagOperation.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/DurableDagOperation.java @@ -1,14 +1,10 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.amazon.lambda.durable.operation; +package software.amazon.lambda.durable.dag; import java.util.Objects; import java.util.function.Consumer; import software.amazon.lambda.durable.DurableFuture; -import software.amazon.lambda.durable.annotations.Experimental; -import software.amazon.lambda.durable.dag.DagConfig; -import software.amazon.lambda.durable.dag.DagContext; -import software.amazon.lambda.durable.dag.DagResult; import software.amazon.lambda.durable.dag.internal.DagContextImpl; import software.amazon.lambda.durable.extension.ExtensionContext; import software.amazon.lambda.durable.util.ParameterValidator; diff --git a/sdk/src/main/java/software/amazon/lambda/durable/annotations/Experimental.java b/dag/src/main/java/software/amazon/lambda/durable/dag/Experimental.java similarity index 94% rename from sdk/src/main/java/software/amazon/lambda/durable/annotations/Experimental.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/Experimental.java index 7bb111bab..2c49d6dcb 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/annotations/Experimental.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/Experimental.java @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.amazon.lambda.durable.annotations; +package software.amazon.lambda.durable.dag; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java b/dag/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java similarity index 90% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java index 0a5af44e4..71be3ef8d 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/SkipReason.java @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.dag; -import software.amazon.lambda.durable.annotations.Experimental; - /** * Explains why a DAG task was {@link TaskStatus#SKIPPED}. * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java similarity index 95% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java index 12e57a4ef..5d286d920 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskExecution.java @@ -4,7 +4,6 @@ import java.time.Instant; import java.util.Optional; -import software.amazon.lambda.durable.annotations.Experimental; /** * The recorded outcome of a single DAG task. diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java similarity index 97% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java index 40f4f7adb..53c7f1a04 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskHandle.java @@ -3,7 +3,6 @@ package software.amazon.lambda.durable.dag; import java.util.function.Predicate; -import software.amazon.lambda.durable.annotations.Experimental; /** * A registration-time reference to a DAG task, carrying the task's result type {@code T} via generics. Returned by each diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java similarity index 92% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java index 2434f8c73..67face681 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/TaskStatus.java @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.dag; -import software.amazon.lambda.durable.annotations.Experimental; - /** * Terminal (or in-progress) status of a single DAG task. * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java b/dag/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java similarity index 90% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java index 3ee34da44..b1f1b981a 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/ThresholdDagCompletion.java @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.dag; -import software.amazon.lambda.durable.annotations.Experimental; import software.amazon.lambda.durable.config.CompletionConfig; /** diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java b/dag/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java similarity index 95% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java index 14b39c1e3..76d854104 100644 --- a/sdk/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java +++ b/dag/src/main/java/software/amazon/lambda/durable/dag/TriggerRule.java @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.dag; -import software.amazon.lambda.durable.annotations.Experimental; - /** * Determines whether a DAG task runs based on the terminal statuses of its upstream dependencies. * diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagContextImpl.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutionOutcome.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutionOutcome.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutionOutcome.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutionOutcome.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutor.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutor.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutor.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagExecutor.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultImpl.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultImpl.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultImpl.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultImpl.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultSerDes.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultSerDes.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultSerDes.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultSerDes.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultTypes.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultTypes.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultTypes.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagResultTypes.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagValidator.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagValidator.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DagValidator.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/DagValidator.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DepsImpl.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/DepsImpl.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/DepsImpl.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/DepsImpl.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContext.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedDagResult.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedDagResult.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedDagResult.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedDagResult.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedResultKind.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedResultKind.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedResultKind.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedResultKind.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedTaskExecution.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedTaskExecution.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedTaskExecution.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/SerializedTaskExecution.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TaskExecutor.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskExecutor.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TaskExecutor.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskExecutor.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TaskHandleImpl.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskHandleImpl.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TaskHandleImpl.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskHandleImpl.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TaskKind.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskKind.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TaskKind.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/TaskKind.java diff --git a/sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluator.java b/dag/src/main/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluator.java similarity index 100% rename from sdk/src/main/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluator.java rename to dag/src/main/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluator.java diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java b/dag/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java similarity index 99% rename from sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java rename to dag/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java index 2925d87bd..821fd71db 100644 --- a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java +++ b/dag/src/test/java/software/amazon/lambda/durable/DagIntegrationTest.java @@ -6,7 +6,7 @@ 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.operation.DurableDagOperation.dag; +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; import org.junit.jupiter.api.Test; import software.amazon.awssdk.services.lambda.model.OperationStatus; diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java similarity index 100% rename from sdk/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java rename to dag/src/test/java/software/amazon/lambda/durable/dag/DagExecutionExceptionTest.java diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java similarity index 100% rename from sdk/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java rename to dag/src/test/java/software/amazon/lambda/durable/dag/DagPredicateExceptionTest.java diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableDagOperationTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/DurableDagOperationTest.java similarity index 98% rename from sdk/src/test/java/software/amazon/lambda/durable/operation/DurableDagOperationTest.java rename to dag/src/test/java/software/amazon/lambda/durable/dag/DurableDagOperationTest.java index 16c0bd1e2..e45e0b77b 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableDagOperationTest.java +++ b/dag/src/test/java/software/amazon/lambda/durable/dag/DurableDagOperationTest.java @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.amazon.lambda.durable.operation; +package software.amazon.lambda.durable.dag; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -24,7 +24,6 @@ import software.amazon.lambda.durable.config.StepConfig; import software.amazon.lambda.durable.config.StepSemantics; import software.amazon.lambda.durable.context.BaseContextImpl; -import software.amazon.lambda.durable.dag.DagResult; import software.amazon.lambda.durable.dag.internal.DagResultSerDes; import software.amazon.lambda.durable.extension.ExtensionContext; import software.amazon.lambda.durable.extension.ExtensionContextConfig; diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java similarity index 100% rename from sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java rename to dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagEnvelopeConvergenceTest.java diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java similarity index 100% rename from sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java rename to dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagResultTest.java diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java similarity index 100% rename from sdk/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java rename to dag/src/test/java/software/amazon/lambda/durable/dag/internal/DagValidatorTest.java diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java similarity index 100% rename from sdk/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java rename to dag/src/test/java/software/amazon/lambda/durable/dag/internal/ReservedOperationContextTest.java diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java similarity index 100% rename from sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java rename to dag/src/test/java/software/amazon/lambda/durable/dag/internal/TaskHandleTest.java diff --git a/sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java b/dag/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java similarity index 100% rename from sdk/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java rename to dag/src/test/java/software/amazon/lambda/durable/dag/internal/TriggerRuleEvaluatorTest.java diff --git a/docs/DAG_STATUS_JAVA.md b/docs/DAG_STATUS_JAVA.md index 499830ee9..c412a7a23 100644 --- a/docs/DAG_STATUS_JAVA.md +++ b/docs/DAG_STATUS_JAVA.md @@ -2,9 +2,12 @@ **Stability:** EXPERIMENTAL (`@Experimental` on public DAG symbols). +**Artifact:** `software.amazon.lambda.durable:aws-durable-execution-sdk-java-dag`. + ## Extension SPI migration - Public entry points are static `DurableDagOperation.dag(...)` and `dagAsync(...)` methods. +- DAG API and implementation classes are isolated from the core SDK in the optional `dag` Maven module. - `DurableContext` has no DAG-specific methods. - The DAG container and tasks use `ExtensionContext` and opaque `ExtensionOperation` reservations. - Task IDs use `reserve(name, "DAG_NODE_T_" + name)`; no DAG-specific operation-ID API is required. diff --git a/docs/core/dag.md b/docs/core/dag.md index 227eb2f61..9f3e0cb35 100644 --- a/docs/core/dag.md +++ b/docs/core/dag.md @@ -2,9 +2,21 @@ > **⚠️ 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 +> `@software.amazon.lambda.durable.dag.Experimental` and carries a Javadoc `@apiNote`. Do not depend on it in > production until it is promoted to stable. +## Installation + +DAG support is distributed as a separate opt-in artifact. Add it alongside the core SDK using the same version: + +```xml + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-dag + ${aws-lambda-durable-sdk.version} + +``` + `DurableDagOperation.dag(...)` declares and runs a **directed acyclic graph of tasks** with typed dependencies. You describe the graph once in a declarative registration phase; the runtime schedules tasks topologically, runs independent chains concurrently via `DurableFuture`, evaluates per-task trigger rules and `runIf` predicates, and aggregates results into @@ -19,7 +31,7 @@ that local ID, so graph traversal order can change without changing task operati ## Entry points ```java -import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; DagResult dag(String name, Consumer register); DagResult dag(String name, Consumer register, DagConfig config); diff --git a/examples/pom.xml b/examples/pom.xml index 4772be59a..af61d34b7 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -31,6 +31,13 @@ ${project.version} + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-dag + ${project.version} + + software.amazon.lambda.durable diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagCompensationExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagCompensationExample.java similarity index 94% rename from examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagCompensationExample.java rename to examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagCompensationExample.java index 636c34722..73b36572a 100644 --- a/examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagCompensationExample.java +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagCompensationExample.java @@ -1,8 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.amazon.lambda.durable.examples.dag; +package software.amazon.lambda.durable.examples.operation.dag; -import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; import software.amazon.lambda.durable.DurableContext; import software.amazon.lambda.durable.DurableHandler; 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/operation/dag/DagDiamondExample.java similarity index 90% rename from examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagDiamondExample.java rename to examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagDiamondExample.java index c27f14964..ec91463a6 100644 --- a/examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagDiamondExample.java +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagDiamondExample.java @@ -1,8 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.amazon.lambda.durable.examples.dag; +package software.amazon.lambda.durable.examples.operation.dag; -import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; import software.amazon.lambda.durable.DurableContext; import software.amazon.lambda.durable.DurableHandler; 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/operation/dag/DagRunIfExample.java similarity index 90% rename from examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagRunIfExample.java rename to examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagRunIfExample.java index 43390deae..c64c7bbb8 100644 --- a/examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagRunIfExample.java +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagRunIfExample.java @@ -1,8 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.amazon.lambda.durable.examples.dag; +package software.amazon.lambda.durable.examples.operation.dag; -import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; import software.amazon.lambda.durable.DurableContext; import software.amazon.lambda.durable.DurableHandler; 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/operation/dag/DagWaitResumeExample.java similarity index 92% rename from examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagWaitResumeExample.java rename to examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagWaitResumeExample.java index 63edab8f6..196713804 100644 --- a/examples/src/main/java/software/amazon/lambda/durable/examples/dag/DagWaitResumeExample.java +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/operation/dag/DagWaitResumeExample.java @@ -1,8 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.amazon.lambda.durable.examples.dag; +package software.amazon.lambda.durable.examples.operation.dag; -import static software.amazon.lambda.durable.operation.DurableDagOperation.dag; +import static software.amazon.lambda.durable.dag.DurableDagOperation.dag; import java.time.Duration; import software.amazon.lambda.durable.DurableContext; 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 8a64ad1c1..0f55923ea 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,69 +866,4 @@ 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/examples/src/test/java/software/amazon/lambda/durable/examples/operation/dag/DagExamplesTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/operation/dag/DagExamplesTest.java new file mode 100644 index 000000000..44e6e2f67 --- /dev/null +++ b/examples/src/test/java/software/amazon/lambda/durable/examples/operation/dag/DagExamplesTest.java @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.operation.dag; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +class DagExamplesTest { + + @Test + void diamondExampleCompletes() { + var runner = LocalDurableTestRunner.create(String.class, new DagDiamondExample()); + + var result = runner.runUntilComplete("go"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("ABAC", result.getResult(String.class)); + } + + @Test + void compensationExampleReportsTaskStatuses() { + var runner = LocalDurableTestRunner.create(String.class, new DagCompensationExample()); + + var result = runner.runUntilComplete("go"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("COMPLETED_WITH_FAILURES|FAILED|SUCCEEDED|SKIPPED|SUCCEEDED", result.getResult(String.class)); + } + + @Test + void runIfExampleReportsSkipCascade() { + var runner = LocalDurableTestRunner.create(String.class, new DagRunIfExample()); + + var result = runner.runUntilComplete("go"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("SKIPPED|SKIPPED", result.getResult(String.class)); + } + + @Test + void waitResumeExampleCompletesAfterReplay() { + var runner = LocalDurableTestRunner.create(String.class, new DagWaitResumeExample()); + + var result = runner.runUntilComplete("go"); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("ABAC", result.getResult(String.class)); + } +} diff --git a/pom.xml b/pom.xml index dc30be7ef..8ec6ad0c3 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ sdk + dag sdk-testing sdk-integration-tests otel-plugin diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagConformanceTest.java b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagConformanceTest.java deleted file mode 100644 index 8982435f0..000000000 --- a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/DagConformanceTest.java +++ /dev/null @@ -1,1029 +0,0 @@ -// 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.operation.DurableDagOperation.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) ───────────────────────────────────────────── - // DurableDagOperation 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 DurableDagOperation}) raises the expected - * typed {@link DagException} for a graph, and records the normalized error token. - */ - private void assertValidation( - String scenario, - Class 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/src/test/java/software/amazon/lambda/durable/execution/DurableExecutionTest.java b/sdk/src/test/java/software/amazon/lambda/durable/execution/DurableExecutionTest.java index 337510bd2..5b7ccbb6f 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/execution/DurableExecutionTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/execution/DurableExecutionTest.java @@ -22,9 +22,7 @@ import software.amazon.awssdk.services.lambda.model.StepDetails; import software.amazon.lambda.durable.DurableConfig; import software.amazon.lambda.durable.TestUtils; -import software.amazon.lambda.durable.dag.DagPredicateException; import software.amazon.lambda.durable.exception.UnrecoverableDurableExecutionException; -import software.amazon.lambda.durable.exception.WaitForConditionFailedException; import software.amazon.lambda.durable.model.DurableExecutionInput; import software.amazon.lambda.durable.model.ExecutionStatus; import software.amazon.lambda.durable.model.OperationSubType; @@ -139,76 +137,6 @@ void testExecuteFailure() { assertEquals("Test error", output.error().errorMessage()); } - @Test - void testExecuteFailureWithUncaughtDagPredicateException() { - // DAG exceptions are extension-level failures, so the standard non-operation error path must preserve the - // concrete type and message in the execution error. - var executionOp = Operation.builder() - .id(EXECUTION_OP_ID) - .type(OperationType.EXECUTION) - .status(OperationStatus.STARTED) - .executionDetails(ExecutionDetails.builder() - .inputPayload("\"test-input\"") - .build()) - .build(); - - var input = new DurableExecutionInput( - EXECUTION_ARN, - "token1", - CheckpointUpdatedExecutionState.builder() - .operations(List.of(executionOp)) - .build()); - - var output = DurableExecutor.execute( - input, - null, - get(String.class), - (userInput, ctx) -> { - throw new DagPredicateException("guarded", new IllegalStateException("predicate boom")); - }, - configWithMockClient()); - - assertEquals(ExecutionStatus.FAILED, output.status()); - assertNotNull(output.error(), "ExecutionFailed must carry a populated error object for a DAG-family exception"); - assertEquals( - "software.amazon.lambda.durable.dag.DagPredicateException", - output.error().errorType()); - assertTrue(output.error().errorMessage().contains("guarded")); - } - - @Test - void testExecuteFailureWithNonDagNullOperationExceptionKeepsPriorBehaviour() { - // A DurableOperationException with no backing operation or error object keeps the existing null-error - // behavior. Extension exceptions do not use this hierarchy. - var executionOp = Operation.builder() - .id(EXECUTION_OP_ID) - .type(OperationType.EXECUTION) - .status(OperationStatus.STARTED) - .executionDetails(ExecutionDetails.builder() - .inputPayload("\"test-input\"") - .build()) - .build(); - - var input = new DurableExecutionInput( - EXECUTION_ARN, - "token1", - CheckpointUpdatedExecutionState.builder() - .operations(List.of(executionOp)) - .build()); - - var output = DurableExecutor.execute( - input, - null, - get(String.class), - (userInput, ctx) -> { - throw new WaitForConditionFailedException("condition failed"); - }, - configWithMockClient()); - - assertEquals(ExecutionStatus.FAILED, output.status()); - assertNull(output.error(), "non-DAG null-operation exception must keep prior null execution error behaviour"); - } - @Test void testRetryableExceptions() { var executionOp = Operation.builder()

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