From 7c79779dcaf95ba5a64cf06e04a76b9fb14eb6a3 Mon Sep 17 00:00:00 2001 From: salaboy Date: Tue, 21 Oct 2025 10:38:04 +0100 Subject: [PATCH 1/8] fixing checkstyle and javadocs Signed-off-by: salaboy --- .github/workflows/build.yml | 28 +- durabletask-client/pom.xml | 167 ++ .../CompositeTaskFailedException.java | 68 + .../io/dapr/durabletask/DataConverter.java | 88 + .../dapr/durabletask/DurableTaskClient.java | 346 ++++ .../durabletask/DurableTaskGrpcClient.java | 423 ++++ .../DurableTaskGrpcClientBuilder.java | 128 ++ .../durabletask/DurableTaskGrpcWorker.java | 328 +++ .../DurableTaskGrpcWorkerBuilder.java | 164 ++ .../io/dapr/durabletask/FailureDetails.java | 145 ++ .../java/io/dapr/durabletask/Helpers.java | 77 + .../durabletask/JacksonDataConverter.java | 58 + .../NewOrchestrationInstanceOptions.java | 147 ++ ...NonDeterministicOrchestratorException.java | 20 + .../durabletask/OrchestrationMetadata.java | 283 +++ .../dapr/durabletask/OrchestrationRunner.java | 169 ++ .../OrchestrationRuntimeStatus.java | 118 ++ .../durabletask/OrchestrationStatusQuery.java | 217 ++ .../OrchestrationStatusQueryResult.java | 53 + .../durabletask/OrchestratorFunction.java | 38 + .../durabletask/PurgeInstanceCriteria.java | 125 ++ .../java/io/dapr/durabletask/PurgeResult.java | 37 + .../io/dapr/durabletask/RetryContext.java | 79 + .../io/dapr/durabletask/RetryHandler.java | 31 + .../java/io/dapr/durabletask/RetryPolicy.java | 176 ++ .../main/java/io/dapr/durabletask/Task.java | 91 + .../io/dapr/durabletask/TaskActivity.java | 45 + .../dapr/durabletask/TaskActivityContext.java | 51 + .../durabletask/TaskActivityExecutor.java | 96 + .../dapr/durabletask/TaskActivityFactory.java | 33 + .../durabletask/TaskCanceledException.java | 26 + .../dapr/durabletask/TaskFailedException.java | 76 + .../java/io/dapr/durabletask/TaskOptions.java | 171 ++ .../dapr/durabletask/TaskOrchestration.java | 82 + .../durabletask/TaskOrchestrationContext.java | 598 ++++++ .../TaskOrchestrationExecutor.java | 1515 ++++++++++++++ .../durabletask/TaskOrchestrationFactory.java | 33 + .../durabletask/TaskOrchestratorResult.java | 40 + .../ContinueAsNewInterruption.java | 32 + .../OrchestratorBlockedException.java | 31 + .../dapr/durabletask/util/UuidGenerator.java | 63 + .../dapr/durabletask/DurableTaskClientIT.java | 1785 +++++++++++++++++ .../DurableTaskGrpcClientTlsTest.java | 342 ++++ .../io/dapr/durabletask/ErrorHandlingIT.java | 306 +++ .../dapr/durabletask/IntegrationTestBase.java | 91 + .../io/dapr/durabletask/TaskOptionsTest.java | 142 ++ pom.xml | 10 +- 47 files changed, 9170 insertions(+), 2 deletions(-) create mode 100644 durabletask-client/pom.xml create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/CompositeTaskFailedException.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/DataConverter.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskClient.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClient.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClientBuilder.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorker.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorkerBuilder.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/FailureDetails.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/Helpers.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/JacksonDataConverter.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/NewOrchestrationInstanceOptions.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/NonDeterministicOrchestratorException.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationMetadata.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationRunner.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationRuntimeStatus.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQuery.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQueryResult.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/OrchestratorFunction.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/PurgeInstanceCriteria.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/PurgeResult.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/RetryContext.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/RetryHandler.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/RetryPolicy.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/Task.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskActivity.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityContext.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityExecutor.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityFactory.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskCanceledException.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskFailedException.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskOptions.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestration.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationContext.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationExecutor.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationFactory.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestratorResult.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/interruption/ContinueAsNewInterruption.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/interruption/OrchestratorBlockedException.java create mode 100644 durabletask-client/src/main/java/io/dapr/durabletask/util/UuidGenerator.java create mode 100644 durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskClientIT.java create mode 100644 durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskGrpcClientTlsTest.java create mode 100644 durabletask-client/src/test/java/io/dapr/durabletask/ErrorHandlingIT.java create mode 100644 durabletask-client/src/test/java/io/dapr/durabletask/IntegrationTestBase.java create mode 100644 durabletask-client/src/test/java/io/dapr/durabletask/TaskOptionsTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e87df82e8..00e7c3910 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -152,7 +152,7 @@ jobs: run: ./mvnw clean install -B -q -DskipTests - name: Integration tests using spring boot version ${{ matrix.spring-boot-version }} id: integration_tests - run: PRODUCT_SPRING_BOOT_VERSION=${{ matrix.spring-boot-version }} ./mvnw -B -Pintegration-tests dependency:copy-dependencies verify + run: PRODUCT_SPRING_BOOT_VERSION=${{ matrix.spring-boot-version }} ./mvnw -B -pl !durabletask-client -Pintegration-tests dependency:copy-dependencies verify env: DOCKER_HOST: ${{steps.setup_docker.outputs.sock}} - name: Upload failsafe test report for sdk-tests on failure @@ -167,6 +167,32 @@ jobs: with: name: surefire-report-sdk-tests-jdk${{ matrix.java }}-sb${{ matrix.spring-boot-version }} path: sdk-tests/target/surefire-reports + # Integration tests for Durable Task Client + - name: Checkout Durable Task Sidecar + uses: actions/checkout@v4 + with: + repository: dapr/durabletask-go + path: durabletask-sidecar + + # TODO: Move the sidecar into a central image repository + - name: Initialize Durable Task Sidecar + run: docker run -d --name durabletask-sidecar -p 4001:4001 --rm -i $(docker build -q ./durabletask-sidecar) + + - name: Display Durable Task Sidecar Logs + run: nohup docker logs --since=0 durabletask-sidecar > durabletask-sidecar.log 2>&1 & + + # wait for 10 seconds, so sidecar container can be fully up, this will avoid intermittent failing issues for integration tests causing by failed to connect to sidecar + - name: Wait for 10 seconds + run: sleep 10 + + - name: Integration Tests For Durable Tasks + run: ./mvnw -B -pl durabletask-client -Pintegration-tests dependency:copy-dependencies verify || echo "TEST_FAILED=true" >> $GITHUB_ENV + continue-on-error: true + + - name: Kill Durable Task Sidecar + run: docker kill durabletask-sidecar + + publish: runs-on: ubuntu-latest diff --git a/durabletask-client/pom.xml b/durabletask-client/pom.xml new file mode 100644 index 000000000..98b1f4ac2 --- /dev/null +++ b/durabletask-client/pom.xml @@ -0,0 +1,167 @@ + + + 4.0.0 + + io.dapr + dapr-sdk-parent + 1.17.0-SNAPSHOT + + + durabletask-client + + + ${project.build.directory}/generated-sources + ${project.build.directory}/proto + + + + + javax.annotation + javax.annotation-api + provided + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + io.grpc + grpc-netty + + + com.google.protobuf + protobuf-java + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + io.grpc + grpc-testing + test + + + org.junit.jupiter + junit-jupiter + test + + + org.testcontainers + testcontainers + + + io.dapr + durabletask-client + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + + + org.apache.maven.plugins + maven-failsafe-plugin + + ${project.build.outputDirectory} + + + + com.googlecode.maven-download-plugin + download-maven-plugin + 1.6.0 + + + getDaprProto + initialize + + wget + + + true + ${durabletask.proto.url} + orchestrator_service.proto + ${protobuf.input.directory} + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + ${protobuf.input.directory} + + + + + compile + compile-custom + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.2.0 + + true + + + + attach-javadocs + + jar + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + + true + + + + + diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/CompositeTaskFailedException.java b/durabletask-client/src/main/java/io/dapr/durabletask/CompositeTaskFailedException.java new file mode 100644 index 000000000..d57ea37d2 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/CompositeTaskFailedException.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import java.util.ArrayList; +import java.util.List; + +/** + * Exception that gets thrown when multiple {@link Task}s for an activity or sub-orchestration fails with an + * unhandled exception. + * + *

Detailed information associated with each task failure can be retrieved using the {@link #getExceptions()} + * method.

+ */ +public class CompositeTaskFailedException extends RuntimeException { + private final List exceptions; + + CompositeTaskFailedException() { + this.exceptions = new ArrayList<>(); + } + + CompositeTaskFailedException(List exceptions) { + this.exceptions = exceptions; + } + + CompositeTaskFailedException(String message, List exceptions) { + super(message); + this.exceptions = exceptions; + } + + CompositeTaskFailedException(String message, Throwable cause, List exceptions) { + super(message, cause); + this.exceptions = exceptions; + } + + CompositeTaskFailedException(Throwable cause, List exceptions) { + super(cause); + this.exceptions = exceptions; + } + + CompositeTaskFailedException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace, + List exceptions) { + super(message, cause, enableSuppression, writableStackTrace); + this.exceptions = exceptions; + } + + /** + * Gets a list of exceptions that occurred during execution of a group of {@link Task}. + * These exceptions include details of the task failure and exception information + * + * @return a list of exceptions + */ + public List getExceptions() { + return new ArrayList<>(this.exceptions); + } + +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/DataConverter.java b/durabletask-client/src/main/java/io/dapr/durabletask/DataConverter.java new file mode 100644 index 000000000..3c2dd7b7e --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/DataConverter.java @@ -0,0 +1,88 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import com.google.protobuf.Timestamp; + +import javax.annotation.Nullable; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +/** + * Interface for serializing and deserializing data that gets passed to and from orchestrators and activities. + * + *

Implementations of this abstract class are free to use any serialization method. Currently, only strings are + * supported as the serialized representation of data. Byte array payloads and streams are not supported by this + * abstraction. Note that these methods all accept null values, in which case the return value should also be null.

+ */ +public interface DataConverter { + /** + * Serializes the input into a text representation. + * + * @param value the value to be serialized + * @return a serialized text representation of the value or null if the value is null + */ + @Nullable + String serialize(@Nullable Object value); + + /** + * Deserializes the given text data into an object of the specified type. + * + * @param data the text data to deserialize into an object + * @param target the target class to deserialize the input into + * @param the generic parameter type representing the target class to deserialize the input into + * @return a deserialized object of type T + * @throws DataConverterException if the text data cannot be deserialized + */ + @Nullable + T deserialize(@Nullable String data, Class target); + + // Data conversion errors are expected to be unrecoverable in most cases, hence an unchecked runtime exception + class DataConverterException extends RuntimeException { + public DataConverterException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * Convert from Timestamp to Instant. + * + * @param ts timestamp to convert + * @return instant + */ + static Instant getInstantFromTimestamp(Timestamp ts) { + if (ts == null) { + return null; + } + + // We don't include nanoseconds because of serialization round-trip issues + return Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()).truncatedTo(ChronoUnit.MILLIS); + } + + /** + * Convert from Instant to Timestamp. + * @param instant to convert + * @return timestamp + */ + static Timestamp getTimestampFromInstant(Instant instant) { + if (instant == null) { + return null; + } + + return Timestamp.newBuilder() + .setSeconds(instant.getEpochSecond()) + .setNanos(instant.getNano()) + .build(); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskClient.java b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskClient.java new file mode 100644 index 000000000..42a98dd55 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskClient.java @@ -0,0 +1,346 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import javax.annotation.Nullable; +import java.time.Duration; +import java.util.concurrent.TimeoutException; + +/** + * Base class that defines client operations for managing orchestration instances. + * + *

Instances of this class can be used to start, query, raise events to, and terminate orchestration instances. + * In most cases, methods on this class accept an instance ID as a parameter, which identifies the orchestration + * instance.

+ * + *

At the time of writing, the most common implementation of this class is DurableTaskGrpcClient, + * which works by making gRPC calls to a remote service (e.g. a sidecar) that implements the operation behavior. To + * ensure any owned network resources are properly released, instances of this class should be closed when they are no + * longer needed.

+ * + *

Instances of this class are expected to be safe for multithreaded apps. You can therefore safely cache instances + * of this class and reuse them across multiple contexts. Caching these objects is useful to improve overall + * performance.

+ */ +public abstract class DurableTaskClient implements AutoCloseable { + + /** + * Releases any network resources held by this object. + */ + @Override + public void close() { + // no default implementation + } + + /** + * Schedules a new orchestration instance with a random ID for execution. + * + * @param orchestratorName the name of the orchestrator to schedule + * @return the randomly-generated instance ID of the scheduled orchestration instance + */ + public String scheduleNewOrchestrationInstance(String orchestratorName) { + return this.scheduleNewOrchestrationInstance(orchestratorName, null, null); + } + + /** + * Schedules a new orchestration instance with a specified input and a random ID for execution. + * + * @param orchestratorName the name of the orchestrator to schedule + * @param input the input to pass to the scheduled orchestration instance. Must be serializable. + * @return the randomly-generated instance ID of the scheduled orchestration instance + */ + public String scheduleNewOrchestrationInstance(String orchestratorName, Object input) { + return this.scheduleNewOrchestrationInstance(orchestratorName, input, null); + } + + /** + * Schedules a new orchestration instance with a specified input and ID for execution. + * + * @param orchestratorName the name of the orchestrator to schedule + * @param input the input to pass to the scheduled orchestration instance. Must be serializable. + * @param instanceId the unique ID of the orchestration instance to schedule + * @return the instanceId parameter value + */ + public String scheduleNewOrchestrationInstance(String orchestratorName, Object input, String instanceId) { + NewOrchestrationInstanceOptions options = new NewOrchestrationInstanceOptions() + .setInput(input) + .setInstanceId(instanceId); + return this.scheduleNewOrchestrationInstance(orchestratorName, options); + } + + /** + * Schedules a new orchestration instance with a specified set of options for execution. + * + * @param orchestratorName the name of the orchestrator to schedule + * @param options the options for the new orchestration instance, including input, instance ID, etc. + * @return the ID of the scheduled orchestration instance, which was either provided in options + * or randomly generated + */ + public abstract String scheduleNewOrchestrationInstance( + String orchestratorName, + NewOrchestrationInstanceOptions options); + + /** + * Sends an event notification message to a waiting orchestration instance. + * + *

In order to handle the event, the target orchestration instance must be waiting for an event named + * eventName using the {@link TaskOrchestrationContext#waitForExternalEvent(String)} method. + * If the target orchestration instance is not yet waiting for an event named eventName, + * then the event will be saved in the orchestration instance state and dispatched immediately when the + * orchestrator calls {@link TaskOrchestrationContext#waitForExternalEvent(String)}. This event saving occurs even + * if the orchestrator has canceled its wait operation before the event was received.

+ * + *

Raised events for a completed or non-existent orchestration instance will be silently discarded.

+ * + * @param instanceId the ID of the orchestration instance that will handle the event + * @param eventName the case-insensitive name of the event + */ + public void raiseEvent(String instanceId, String eventName) { + this.raiseEvent(instanceId, eventName, null); + } + + /** + * Sends an event notification message with a payload to a waiting orchestration instance. + * + *

In order to handle the event, the target orchestration instance must be waiting for an event named + * eventName using the {@link TaskOrchestrationContext#waitForExternalEvent(String)} method. + * If the target orchestration instance is not yet waiting for an event named eventName, + * then the event will be saved in the orchestration instance state and dispatched immediately when the + * orchestrator calls {@link TaskOrchestrationContext#waitForExternalEvent(String)}. This event saving occurs even + * if the orchestrator has canceled its wait operation before the event was received.

+ * + *

Raised events for a completed or non-existent orchestration instance will be silently discarded.

+ * + * @param instanceId the ID of the orchestration instance that will handle the event + * @param eventName the case-insensitive name of the event + * @param eventPayload the serializable data payload to include with the event + */ + public abstract void raiseEvent(String instanceId, String eventName, @Nullable Object eventPayload); + + /** + * Fetches orchestration instance metadata from the configured durable store. + * + * @param instanceId the unique ID of the orchestration instance to fetch + * @param getInputsAndOutputs true to fetch the orchestration instance's inputs, outputs, and custom + * status, or false to omit them + * @return a metadata record that describes the orchestration instance and its execution status, or + * a default instance if no such instance is found. Please refer to method + * {@link OrchestrationMetadata#isInstanceFound()} to check if an instance is found. + */ + @Nullable + public abstract OrchestrationMetadata getInstanceMetadata(String instanceId, boolean getInputsAndOutputs); + + /** + * Waits for an orchestration to start running and returns an {@link OrchestrationMetadata} object that contains + * metadata about the started instance. + * + *

A "started" orchestration instance is any instance not in the Pending state.

+ * + *

If an orchestration instance is already running when this method is called, the method will return immediately. + *

+ * + *

Note that this method overload will not fetch the orchestration's inputs, outputs, or custom status payloads. + *

+ * + * @param instanceId the unique ID of the orchestration instance to wait for + * @param timeout the amount of time to wait for the orchestration instance to start + * @return the orchestration instance metadata or null if no such instance is found + * @throws TimeoutException when the orchestration instance is not started within the specified amount of time + */ + @Nullable + public OrchestrationMetadata waitForInstanceStart(String instanceId, Duration timeout) throws TimeoutException { + return this.waitForInstanceStart(instanceId, timeout, false); + } + + /** + * Waits for an orchestration to start running and returns an {@link OrchestrationMetadata} object that contains + * metadata about the started instance and optionally its input, output, and custom status payloads. + * + *

A "started" orchestration instance is any instance not in the Pending state.

+ * + *

If an orchestration instance is already running when this method is called, the method will return immediately. + *

+ * + * @param instanceId the unique ID of the orchestration instance to wait for + * @param timeout the amount of time to wait for the orchestration instance to start + * @param getInputsAndOutputs true to fetch the orchestration instance's inputs, outputs, and custom + * status, or false to omit them + * @return the orchestration instance metadata or null if no such instance is found + * @throws TimeoutException when the orchestration instance is not started within the specified amount of time + */ + @Nullable + public abstract OrchestrationMetadata waitForInstanceStart( + String instanceId, + Duration timeout, + boolean getInputsAndOutputs) throws TimeoutException; + + /** + * Waits for an orchestration to complete and returns an {@link OrchestrationMetadata} object that contains + * metadata about the completed instance. + * + *

A "completed" orchestration instance is any instance in one of the terminal states. For example, the + * Completed, Failed, or Terminated states.

+ * + *

Orchestrations are long-running and could take hours, days, or months before completing. + * Orchestrations can also be eternal, in which case they'll never complete unless terminated. + * In such cases, this call may block indefinitely, so care must be taken to ensure appropriate timeouts are used. + *

+ * + *

If an orchestration instance is already complete when this method is called, the method will return immediately. + *

+ * @param instanceId the unique ID of the orchestration instance to wait for + * @param timeout the amount of time to wait for the orchestration instance to complete + * @param getInputsAndOutputs true to fetch the orchestration instance's inputs, outputs, and custom + * status, or false to omit them + * @return the orchestration instance metadata or null if no such instance is found + * @throws TimeoutException when the orchestration instance is not completed within the specified amount of time + */ + @Nullable + public abstract OrchestrationMetadata waitForInstanceCompletion( + String instanceId, + Duration timeout, + boolean getInputsAndOutputs) throws TimeoutException; + + /** + * Terminates a running orchestration instance and updates its runtime status to Terminated. + * + *

This method internally enqueues a "terminate" message in the task hub. When the task hub worker processes + * this message, it will update the runtime status of the target instance to Terminated. + * You can use the {@link #waitForInstanceCompletion} to wait for the instance to reach the terminated state. + *

+ * + *

Terminating an orchestration instance has no effect on any in-flight activity function executions + * or sub-orchestrations that were started by the terminated instance. Those actions will continue to run + * without interruption. However, their results will be discarded. If you want to terminate sub-orchestrations, + * you must issue separate terminate commands for each sub-orchestration instance.

+ * + *

At the time of writing, there is no way to terminate an in-flight activity execution.

+ * + *

Attempting to terminate a completed or non-existent orchestration instance will fail silently.

+ * + * @param instanceId the unique ID of the orchestration instance to terminate + * @param output the optional output to set for the terminated orchestration instance. + * This value must be serializable. + */ + public abstract void terminate(String instanceId, @Nullable Object output); + + /** + * Fetches orchestration instance metadata from the configured durable store using a status query filter. + * + * @param query filter criteria that determines which orchestrations to fetch data for. + * @return the result of the query operation, including instance metadata and possibly a continuation token + */ + public abstract OrchestrationStatusQueryResult queryInstances(OrchestrationStatusQuery query); + + /** + * Initializes the target task hub data store. + * + *

This is an administrative operation that only needs to be done once for the lifetime of the task hub.

+ * + * @param recreateIfExists true to delete any existing task hub first; false to make this + * operation a no-op if the task hub data store already exists. Note that deleting a task + * hub will result in permanent data loss. Use this operation with care. + */ + public abstract void createTaskHub(boolean recreateIfExists); + + /** + * Permanently deletes the target task hub data store and any orchestration data it may contain. + * + *

This is an administrative operation that is irreversible. It should be used with great care.

+ */ + public abstract void deleteTaskHub(); + + /** + * Purges orchestration instance metadata from the durable store. + * + *

This method can be used to permanently delete orchestration metadata from the underlying storage provider, + * including any stored inputs, outputs, and orchestration history records. This is often useful for implementing + * data retention policies and for keeping storage costs minimal. Only orchestration instances in the + * Completed, Failed, or Terminated state can be purged.

+ * + *

If the target orchestration instance is not found in the data store, or if the instance is found but not in a + * terminal state, then the returned {@link PurgeResult} will report that zero instances were purged. + * Otherwise, the existing data will be purged and the returned {@link PurgeResult} will report that one instance + * was purged.

+ * + * @param instanceId the unique ID of the orchestration instance to purge + * @return the result of the purge operation, including the number of purged orchestration instances (0 or 1) + */ + public abstract PurgeResult purgeInstance(String instanceId); + + /** + * Purges orchestration instance metadata from the durable store using a filter that determines which instances to + * purge data for. + * + *

This method can be used to permanently delete orchestration metadata from the underlying storage provider, + * including any stored inputs, outputs, and orchestration history records. This is often useful for implementing + * data retention policies and for keeping storage costs minimal. Only orchestration instances in the + * Completed, Failed, or Terminated state can be purged.

+ * + *

Depending on the type of the durable store, purge operations that target multiple orchestration instances may + * take a long time to complete and be resource intensive. It may therefore be useful to break up purge operations + * into multiple method calls over a period of time and have them cover smaller time windows.

+ * + * @param purgeInstanceCriteria orchestration instance filter criteria used to determine which instances to purge + * @return the result of the purge operation, including the number of purged orchestration instances (0 or 1) + * @throws TimeoutException when purging instances is not completed within the specified amount of time. + * The default timeout for purging instances is 10 minutes + */ + public abstract PurgeResult purgeInstances(PurgeInstanceCriteria purgeInstanceCriteria) throws TimeoutException; + + /** + * Restarts an existing orchestration instance with the original input. + * + * @param instanceId the ID of the previously run orchestration instance to restart. + * @param restartWithNewInstanceId true to restart the orchestration instance with a new instance ID + * false to restart the orchestration instance with same instance ID + * @return the ID of the scheduled orchestration instance, which is either instanceId or randomly + * generated depending on the value of restartWithNewInstanceId + */ + public abstract String restartInstance(String instanceId, boolean restartWithNewInstanceId); + + /** + * Suspends a running orchestration instance. + * + * @param instanceId the ID of the orchestration instance to suspend + */ + public void suspendInstance(String instanceId) { + this.suspendInstance(instanceId, null); + } + + /** + * Suspends a running orchestration instance. + * + * @param instanceId the ID of the orchestration instance to suspend + * @param reason the reason for suspending the orchestration instance + */ + public abstract void suspendInstance(String instanceId, @Nullable String reason); + + /** + * Resumes a running orchestration instance. + * + * @param instanceId the ID of the orchestration instance to resume + */ + public void resumeInstance(String instanceId) { + this.resumeInstance(instanceId, null); + } + + /** + * Resumes a running orchestration instance. + * + * @param instanceId the ID of the orchestration instance to resume + * @param reason the reason for resuming the orchestration instance + */ + public abstract void resumeInstance(String instanceId, @Nullable String reason); +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClient.java b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClient.java new file mode 100644 index 000000000..b0fa24a5e --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClient.java @@ -0,0 +1,423 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import com.google.protobuf.StringValue; +import com.google.protobuf.Timestamp; +import io.dapr.durabletask.implementation.protobuf.OrchestratorService; +import io.dapr.durabletask.implementation.protobuf.TaskHubSidecarServiceGrpc; +import io.grpc.Channel; +import io.grpc.ChannelCredentials; +import io.grpc.Grpc; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.TlsChannelCredentials; +import io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.NettyChannelBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; + +import javax.annotation.Nullable; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.Logger; + +/** + * Durable Task client implementation that uses gRPC to connect to a remote "sidecar" process. + */ +public final class DurableTaskGrpcClient extends DurableTaskClient { + private static final int DEFAULT_PORT = 4001; + private static final Logger logger = Logger.getLogger(DurableTaskGrpcClient.class.getPackage().getName()); + private static final String GRPC_TLS_CA_PATH = "DAPR_GRPC_TLS_CA_PATH"; + private static final String GRPC_TLS_CERT_PATH = "DAPR_GRPC_TLS_CERT_PATH"; + private static final String GRPC_TLS_KEY_PATH = "DAPR_GRPC_TLS_KEY_PATH"; + private static final String GRPC_TLS_INSECURE = "DAPR_GRPC_TLS_INSECURE"; + + private final DataConverter dataConverter; + private final ManagedChannel managedSidecarChannel; + private final TaskHubSidecarServiceGrpc.TaskHubSidecarServiceBlockingStub sidecarClient; + + DurableTaskGrpcClient(DurableTaskGrpcClientBuilder builder) { + this.dataConverter = builder.dataConverter != null ? builder.dataConverter : new JacksonDataConverter(); + + Channel sidecarGrpcChannel; + if (builder.channel != null) { + // The caller is responsible for managing the channel lifetime + this.managedSidecarChannel = null; + sidecarGrpcChannel = builder.channel; + } else { + // Construct our own channel using localhost + a port number + int port = DEFAULT_PORT; + if (builder.port > 0) { + port = builder.port; + } + + String endpoint = "localhost:" + port; + ManagedChannelBuilder channelBuilder; + + // Get TLS configuration from builder or environment variables + String tlsCaPath = builder.tlsCaPath != null ? builder.tlsCaPath : System.getenv(GRPC_TLS_CA_PATH); + String tlsCertPath = builder.tlsCertPath != null ? builder.tlsCertPath : System.getenv(GRPC_TLS_CERT_PATH); + String tlsKeyPath = builder.tlsKeyPath != null ? builder.tlsKeyPath : System.getenv(GRPC_TLS_KEY_PATH); + boolean insecure = builder.insecure || Boolean.parseBoolean(System.getenv(GRPC_TLS_INSECURE)); + + if (insecure) { + // Insecure mode - uses TLS but doesn't verify certificates + try { + channelBuilder = NettyChannelBuilder.forTarget(endpoint) + .sslContext(GrpcSslContexts.forClient() + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .build()); + } catch (Exception e) { + throw new RuntimeException("Failed to create insecure TLS credentials", e); + } + } else if (tlsCertPath != null && tlsKeyPath != null) { + // mTLS case - using client cert and key, with optional CA cert for server authentication + try ( + InputStream clientCertInputStream = new FileInputStream(tlsCertPath); + InputStream clientKeyInputStream = new FileInputStream(tlsKeyPath); + InputStream caCertInputStream = tlsCaPath != null ? new FileInputStream(tlsCaPath) : null + ) { + TlsChannelCredentials.Builder tlsBuilder = TlsChannelCredentials.newBuilder() + .keyManager(clientCertInputStream, clientKeyInputStream); // For client authentication + if (caCertInputStream != null) { + tlsBuilder.trustManager(caCertInputStream); // For server authentication + } + ChannelCredentials credentials = tlsBuilder.build(); + channelBuilder = Grpc.newChannelBuilder(endpoint, credentials); + } catch (IOException e) { + throw new RuntimeException("Failed to create mTLS credentials" + + (tlsCaPath != null ? " with CA cert" : ""), e); + } + } else if (tlsCaPath != null) { + // Simple TLS case - using CA cert only for server authentication + try (InputStream caCertInputStream = new FileInputStream(tlsCaPath)) { + ChannelCredentials credentials = TlsChannelCredentials.newBuilder() + .trustManager(caCertInputStream) + .build(); + channelBuilder = Grpc.newChannelBuilder(endpoint, credentials); + } catch (IOException e) { + throw new RuntimeException("Failed to create TLS credentials with CA cert", e); + } + } else { + // No TLS config provided, use plaintext + channelBuilder = ManagedChannelBuilder.forTarget(endpoint).usePlaintext(); + } + + // Need to keep track of this channel so we can dispose it on close() + this.managedSidecarChannel = channelBuilder.build(); + sidecarGrpcChannel = this.managedSidecarChannel; + } + + this.sidecarClient = TaskHubSidecarServiceGrpc.newBlockingStub(sidecarGrpcChannel); + } + + /** + * Closes the internally managed gRPC channel, if one exists. + * + *

This method is a no-op if this client object was created using a builder with a gRPC channel object explicitly + * configured.

+ */ + @Override + public void close() { + if (this.managedSidecarChannel != null) { + try { + this.managedSidecarChannel.shutdown().awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // Best effort. Also note that AutoClose documentation recommends NOT having + // close() methods throw InterruptedException: + // https://docs.oracle.com/javase/7/docs/api/java/lang/AutoCloseable.html + } + } + } + + @Override + public String scheduleNewOrchestrationInstance( + String orchestratorName, + NewOrchestrationInstanceOptions options) { + if (orchestratorName == null || orchestratorName.length() == 0) { + throw new IllegalArgumentException("A non-empty orchestrator name must be specified."); + } + + Helpers.throwIfArgumentNull(options, "options"); + + OrchestratorService.CreateInstanceRequest.Builder builder = OrchestratorService.CreateInstanceRequest.newBuilder(); + builder.setName(orchestratorName); + + String instanceId = options.getInstanceId(); + if (instanceId == null) { + instanceId = UUID.randomUUID().toString(); + } + builder.setInstanceId(instanceId); + + String version = options.getVersion(); + if (version != null) { + builder.setVersion(StringValue.of(version)); + } + + Object input = options.getInput(); + if (input != null) { + String serializedInput = this.dataConverter.serialize(input); + builder.setInput(StringValue.of(serializedInput)); + } + + Instant startTime = options.getStartTime(); + if (startTime != null) { + Timestamp ts = DataConverter.getTimestampFromInstant(startTime); + builder.setScheduledStartTimestamp(ts); + } + + OrchestratorService.CreateInstanceRequest request = builder.build(); + OrchestratorService.CreateInstanceResponse response = this.sidecarClient.startInstance(request); + return response.getInstanceId(); + } + + @Override + public void raiseEvent(String instanceId, String eventName, Object eventPayload) { + Helpers.throwIfArgumentNull(instanceId, "instanceId"); + Helpers.throwIfArgumentNull(eventName, "eventName"); + + OrchestratorService.RaiseEventRequest.Builder builder = OrchestratorService.RaiseEventRequest.newBuilder() + .setInstanceId(instanceId) + .setName(eventName); + if (eventPayload != null) { + String serializedPayload = this.dataConverter.serialize(eventPayload); + builder.setInput(StringValue.of(serializedPayload)); + } + + OrchestratorService.RaiseEventRequest request = builder.build(); + this.sidecarClient.raiseEvent(request); + } + + @Override + public OrchestrationMetadata getInstanceMetadata(String instanceId, boolean getInputsAndOutputs) { + OrchestratorService.GetInstanceRequest request = OrchestratorService.GetInstanceRequest.newBuilder() + .setInstanceId(instanceId) + .setGetInputsAndOutputs(getInputsAndOutputs) + .build(); + OrchestratorService.GetInstanceResponse response = this.sidecarClient.getInstance(request); + return new OrchestrationMetadata(response, this.dataConverter, request.getGetInputsAndOutputs()); + } + + @Override + public OrchestrationMetadata waitForInstanceStart(String instanceId, Duration timeout, boolean getInputsAndOutputs) + throws TimeoutException { + OrchestratorService.GetInstanceRequest request = OrchestratorService.GetInstanceRequest.newBuilder() + .setInstanceId(instanceId) + .setGetInputsAndOutputs(getInputsAndOutputs) + .build(); + + if (timeout == null || timeout.isNegative() || timeout.isZero()) { + timeout = Duration.ofMinutes(10); + } + + TaskHubSidecarServiceGrpc.TaskHubSidecarServiceBlockingStub grpcClient = this.sidecarClient.withDeadlineAfter( + timeout.toMillis(), + TimeUnit.MILLISECONDS); + + OrchestratorService.GetInstanceResponse response; + try { + response = grpcClient.waitForInstanceStart(request); + } catch (StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.DEADLINE_EXCEEDED) { + throw new TimeoutException("Start orchestration timeout reached."); + } + throw e; + } + return new OrchestrationMetadata(response, this.dataConverter, request.getGetInputsAndOutputs()); + } + + @Override + public OrchestrationMetadata waitForInstanceCompletion(String instanceId, Duration timeout, + boolean getInputsAndOutputs) throws TimeoutException { + OrchestratorService.GetInstanceRequest request = OrchestratorService.GetInstanceRequest.newBuilder() + .setInstanceId(instanceId) + .setGetInputsAndOutputs(getInputsAndOutputs) + .build(); + + if (timeout == null || timeout.isNegative() || timeout.isZero()) { + timeout = Duration.ofMinutes(10); + } + + TaskHubSidecarServiceGrpc.TaskHubSidecarServiceBlockingStub grpcClient = this.sidecarClient.withDeadlineAfter( + timeout.toMillis(), + TimeUnit.MILLISECONDS); + + OrchestratorService.GetInstanceResponse response; + try { + response = grpcClient.waitForInstanceCompletion(request); + } catch (StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.DEADLINE_EXCEEDED) { + throw new TimeoutException("Orchestration instance completion timeout reached."); + } + throw e; + } + return new OrchestrationMetadata(response, this.dataConverter, request.getGetInputsAndOutputs()); + } + + @Override + public void terminate(String instanceId, @Nullable Object output) { + Helpers.throwIfArgumentNull(instanceId, "instanceId"); + String serializeOutput = this.dataConverter.serialize(output); + this.logger.fine(() -> String.format( + "Terminating instance %s and setting output to: %s", + instanceId, + serializeOutput != null ? serializeOutput : "(null)")); + OrchestratorService.TerminateRequest.Builder builder = OrchestratorService.TerminateRequest.newBuilder() + .setInstanceId(instanceId); + if (serializeOutput != null) { + builder.setOutput(StringValue.of(serializeOutput)); + } + this.sidecarClient.terminateInstance(builder.build()); + } + + @Override + public OrchestrationStatusQueryResult queryInstances(OrchestrationStatusQuery query) { + OrchestratorService.InstanceQuery.Builder instanceQueryBuilder = OrchestratorService.InstanceQuery.newBuilder(); + Optional.ofNullable(query.getCreatedTimeFrom()).ifPresent(createdTimeFrom -> + instanceQueryBuilder.setCreatedTimeFrom(DataConverter.getTimestampFromInstant(createdTimeFrom))); + Optional.ofNullable(query.getCreatedTimeTo()).ifPresent(createdTimeTo -> + instanceQueryBuilder.setCreatedTimeTo(DataConverter.getTimestampFromInstant(createdTimeTo))); + Optional.ofNullable(query.getContinuationToken()).ifPresent(token -> + instanceQueryBuilder.setContinuationToken(StringValue.of(token))); + Optional.ofNullable(query.getInstanceIdPrefix()).ifPresent(prefix -> + instanceQueryBuilder.setInstanceIdPrefix(StringValue.of(prefix))); + instanceQueryBuilder.setFetchInputsAndOutputs(query.isFetchInputsAndOutputs()); + instanceQueryBuilder.setMaxInstanceCount(query.getMaxInstanceCount()); + query.getRuntimeStatusList().forEach(runtimeStatus -> + Optional.ofNullable(runtimeStatus).ifPresent(status -> + instanceQueryBuilder.addRuntimeStatus(OrchestrationRuntimeStatus.toProtobuf(status)))); + query.getTaskHubNames().forEach(taskHubName -> Optional.ofNullable(taskHubName).ifPresent(name -> + instanceQueryBuilder.addTaskHubNames(StringValue.of(name)))); + OrchestratorService.QueryInstancesResponse queryInstancesResponse = this.sidecarClient + .queryInstances(OrchestratorService.QueryInstancesRequest.newBuilder().setQuery(instanceQueryBuilder).build()); + return toQueryResult(queryInstancesResponse, query.isFetchInputsAndOutputs()); + } + + private OrchestrationStatusQueryResult toQueryResult( + OrchestratorService.QueryInstancesResponse queryInstancesResponse, boolean fetchInputsAndOutputs) { + List metadataList = new ArrayList<>(); + queryInstancesResponse.getOrchestrationStateList().forEach(state -> { + metadataList.add(new OrchestrationMetadata(state, this.dataConverter, fetchInputsAndOutputs)); + }); + return new OrchestrationStatusQueryResult(metadataList, queryInstancesResponse.getContinuationToken().getValue()); + } + + @Override + public void createTaskHub(boolean recreateIfExists) { + this.sidecarClient.createTaskHub(OrchestratorService.CreateTaskHubRequest.newBuilder() + .setRecreateIfExists(recreateIfExists).build()); + } + + @Override + public void deleteTaskHub() { + this.sidecarClient.deleteTaskHub(OrchestratorService.DeleteTaskHubRequest.newBuilder().build()); + } + + @Override + public PurgeResult purgeInstance(String instanceId) { + OrchestratorService.PurgeInstancesRequest request = OrchestratorService.PurgeInstancesRequest.newBuilder() + .setInstanceId(instanceId) + .build(); + + OrchestratorService.PurgeInstancesResponse response = this.sidecarClient.purgeInstances(request); + return toPurgeResult(response); + } + + @Override + public PurgeResult purgeInstances(PurgeInstanceCriteria purgeInstanceCriteria) throws TimeoutException { + OrchestratorService.PurgeInstanceFilter.Builder builder = OrchestratorService.PurgeInstanceFilter.newBuilder(); + builder.setCreatedTimeFrom(DataConverter.getTimestampFromInstant(purgeInstanceCriteria.getCreatedTimeFrom())); + Optional.ofNullable(purgeInstanceCriteria.getCreatedTimeTo()).ifPresent(createdTimeTo -> + builder.setCreatedTimeTo(DataConverter.getTimestampFromInstant(createdTimeTo))); + purgeInstanceCriteria.getRuntimeStatusList().forEach(runtimeStatus -> + Optional.ofNullable(runtimeStatus).ifPresent(status -> + builder.addRuntimeStatus(OrchestrationRuntimeStatus.toProtobuf(status)))); + + Duration timeout = purgeInstanceCriteria.getTimeout(); + if (timeout == null || timeout.isNegative() || timeout.isZero()) { + timeout = Duration.ofMinutes(4); + } + + TaskHubSidecarServiceGrpc.TaskHubSidecarServiceBlockingStub grpcClient = this.sidecarClient.withDeadlineAfter( + timeout.toMillis(), + TimeUnit.MILLISECONDS); + + OrchestratorService.PurgeInstancesResponse response; + try { + response = grpcClient.purgeInstances(OrchestratorService.PurgeInstancesRequest.newBuilder() + .setPurgeInstanceFilter(builder).build()); + return toPurgeResult(response); + } catch (StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.DEADLINE_EXCEEDED) { + String timeOutException = String.format("Purge instances timeout duration of %s reached.", timeout); + throw new TimeoutException(timeOutException); + } + throw e; + } + } + + @Override + public void suspendInstance(String instanceId, @Nullable String reason) { + OrchestratorService.SuspendRequest.Builder suspendRequestBuilder = OrchestratorService.SuspendRequest.newBuilder(); + suspendRequestBuilder.setInstanceId(instanceId); + if (reason != null) { + suspendRequestBuilder.setReason(StringValue.of(reason)); + } + this.sidecarClient.suspendInstance(suspendRequestBuilder.build()); + } + + @Override + public void resumeInstance(String instanceId, @Nullable String reason) { + OrchestratorService.ResumeRequest.Builder resumeRequestBuilder = OrchestratorService.ResumeRequest.newBuilder(); + resumeRequestBuilder.setInstanceId(instanceId); + if (reason != null) { + resumeRequestBuilder.setReason(StringValue.of(reason)); + } + this.sidecarClient.resumeInstance(resumeRequestBuilder.build()); + } + + @Override + public String restartInstance(String instanceId, boolean restartWithNewInstanceId) { + OrchestrationMetadata metadata = this.getInstanceMetadata(instanceId, true); + if (!metadata.isInstanceFound()) { + throw new IllegalArgumentException(new StringBuilder() + .append("An orchestration with instanceId ") + .append(instanceId) + .append(" was not found.").toString()); + } + + if (restartWithNewInstanceId) { + return this.scheduleNewOrchestrationInstance(metadata.getName(), + this.dataConverter.deserialize(metadata.getSerializedInput(), Object.class)); + } else { + return this.scheduleNewOrchestrationInstance(metadata.getName(), + this.dataConverter.deserialize(metadata.getSerializedInput(), Object.class), metadata.getInstanceId()); + } + } + + private PurgeResult toPurgeResult(OrchestratorService.PurgeInstancesResponse response) { + return new PurgeResult(response.getDeletedInstanceCount()); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClientBuilder.java b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClientBuilder.java new file mode 100644 index 000000000..f3ba1cd82 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcClientBuilder.java @@ -0,0 +1,128 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import io.grpc.Channel; + +/** + * Builder class for constructing new {@link DurableTaskClient} objects that communicate with a sidecar process + * over gRPC. + */ +public final class DurableTaskGrpcClientBuilder { + DataConverter dataConverter; + int port; + Channel channel; + String tlsCaPath; + String tlsCertPath; + String tlsKeyPath; + boolean insecure; + + /** + * Sets the {@link DataConverter} to use for converting serializable data payloads. + * + * @param dataConverter the {@link DataConverter} to use for converting serializable data payloads + * @return this builder object + */ + public DurableTaskGrpcClientBuilder dataConverter(DataConverter dataConverter) { + this.dataConverter = dataConverter; + return this; + } + + /** + * Sets the gRPC channel to use for communicating with the sidecar process. + * + *

This builder method allows you to provide your own gRPC channel for communicating with the Durable Task sidecar + * endpoint. Channels provided using this method won't be closed when the client is closed. + * Rather, the caller remains responsible for shutting down the channel after disposing the client.

+ * + *

If not specified, a gRPC channel will be created automatically for each constructed + * {@link DurableTaskClient}.

+ * + * @param channel the gRPC channel to use + * @return this builder object + */ + public DurableTaskGrpcClientBuilder grpcChannel(Channel channel) { + this.channel = channel; + return this; + } + + /** + * Sets the gRPC endpoint port to connect to. If not specified, the default Durable Task port number will be used. + * + * @param port the gRPC endpoint port to connect to + * @return this builder object + */ + public DurableTaskGrpcClientBuilder port(int port) { + this.port = port; + return this; + } + + /** + * Sets the path to the TLS CA certificate file for server authentication. + * If not set, the system's default CA certificates will be used. + * + * @param tlsCaPath path to the TLS CA certificate file + * @return this builder object + */ + public DurableTaskGrpcClientBuilder tlsCaPath(String tlsCaPath) { + this.tlsCaPath = tlsCaPath; + return this; + } + + /** + * Sets the path to the TLS client certificate file for client authentication. + * This is used for mTLS (mutual TLS) connections. + * + * @param tlsCertPath path to the TLS client certificate file + * @return this builder object + */ + public DurableTaskGrpcClientBuilder tlsCertPath(String tlsCertPath) { + this.tlsCertPath = tlsCertPath; + return this; + } + + /** + * Sets the path to the TLS client key file for client authentication. + * This is used for mTLS (mutual TLS) connections. + * + * @param tlsKeyPath path to the TLS client key file + * @return this builder object + */ + public DurableTaskGrpcClientBuilder tlsKeyPath(String tlsKeyPath) { + this.tlsKeyPath = tlsKeyPath; + return this; + } + + /** + * Sets whether to use insecure (plaintext) mode for gRPC communication. + * When set to true, TLS will be disabled and communication will be unencrypted. + * This should only be used for development/testing. + * + * @param insecure whether to use insecure mode + * @return this builder object + */ + public DurableTaskGrpcClientBuilder insecure(boolean insecure) { + this.insecure = insecure; + return this; + } + + /** + * Initializes a new {@link DurableTaskClient} object with the settings specified in the current builder object. + * + * @return a new {@link DurableTaskClient} object + */ + public DurableTaskClient build() { + return new DurableTaskGrpcClient(this); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorker.java b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorker.java new file mode 100644 index 000000000..eb3be6bb9 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorker.java @@ -0,0 +1,328 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import com.google.protobuf.StringValue; +import io.dapr.durabletask.implementation.protobuf.OrchestratorService; +import io.dapr.durabletask.implementation.protobuf.OrchestratorService.TaskFailureDetails; +import io.dapr.durabletask.implementation.protobuf.TaskHubSidecarServiceGrpc; +import io.grpc.Channel; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Iterator; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Task hub worker that connects to a sidecar process over gRPC to execute + * orchestrator and activity events. + */ +public final class DurableTaskGrpcWorker implements AutoCloseable { + + private static final int DEFAULT_PORT = 4001; + private static final Logger logger = Logger.getLogger(DurableTaskGrpcWorker.class.getPackage().getName()); + private static final Duration DEFAULT_MAXIMUM_TIMER_INTERVAL = Duration.ofDays(3); + + private final HashMap orchestrationFactories = new HashMap<>(); + private final HashMap activityFactories = new HashMap<>(); + + private final ManagedChannel managedSidecarChannel; + private final DataConverter dataConverter; + private final Duration maximumTimerInterval; + private final ExecutorService workerPool; + private final String appId; // App ID for cross-app routing + + private final TaskHubSidecarServiceGrpc.TaskHubSidecarServiceBlockingStub sidecarClient; + private final boolean isExecutorServiceManaged; + private volatile boolean isNormalShutdown = false; + private Thread workerThread; + + DurableTaskGrpcWorker(DurableTaskGrpcWorkerBuilder builder) { + this.orchestrationFactories.putAll(builder.orchestrationFactories); + this.activityFactories.putAll(builder.activityFactories); + this.appId = builder.appId; + + Channel sidecarGrpcChannel; + if (builder.channel != null) { + // The caller is responsible for managing the channel lifetime + this.managedSidecarChannel = null; + sidecarGrpcChannel = builder.channel; + } else { + // Construct our own channel using localhost + a port number + int port = DEFAULT_PORT; + if (builder.port > 0) { + port = builder.port; + } + + // Need to keep track of this channel so we can dispose it on close() + this.managedSidecarChannel = ManagedChannelBuilder + .forAddress("localhost", port) + .usePlaintext() + .build(); + sidecarGrpcChannel = this.managedSidecarChannel; + } + + this.sidecarClient = TaskHubSidecarServiceGrpc.newBlockingStub(sidecarGrpcChannel); + this.dataConverter = builder.dataConverter != null ? builder.dataConverter : new JacksonDataConverter(); + this.maximumTimerInterval = builder.maximumTimerInterval != null ? builder.maximumTimerInterval + : DEFAULT_MAXIMUM_TIMER_INTERVAL; + this.workerPool = builder.executorService != null ? builder.executorService : Executors.newCachedThreadPool(); + this.isExecutorServiceManaged = builder.executorService == null; + } + + /** + * Establishes a gRPC connection to the sidecar and starts processing work-items + * in the background. + * + *

This method retries continuously to establish a connection to the sidecar. If + * a connection fails, + * a warning log message will be written and a new connection attempt will be + * made. This process + * continues until either a connection succeeds or the process receives an + * interrupt signal.

+ */ + public void start() { + this.workerThread = new Thread(this::startAndBlock); + this.workerThread.start(); + } + + /** + * Closes the internally managed gRPC channel and executor service, if one + * exists. + * + *

Only the internally managed GRPC Channel and Executor services are closed. If + * any of them are supplied, + * it is the responsibility of the supplier to take care of them.

+ * + */ + public void close() { + this.workerThread.interrupt(); + this.isNormalShutdown = true; + this.shutDownWorkerPool(); + this.closeSideCarChannel(); + } + + /** + * Establishes a gRPC connection to the sidecar and starts processing work-items + * on the current thread. + * This method call blocks indefinitely, or until the current thread is + * interrupted. + * + *

Use can alternatively use the {@link #start} method to run orchestration + * processing in a background thread.

+ * + *

This method retries continuously to establish a connection to the sidecar. If + * a connection fails, + * a warning log message will be written and a new connection attempt will be + * made. This process + * continues until either a connection succeeds or the process receives an + * interrupt signal.

+ */ + public void startAndBlock() { + logger.log(Level.INFO, "Durable Task worker is connecting to sidecar at {0}.", this.getSidecarAddress()); + + TaskOrchestrationExecutor taskOrchestrationExecutor = new TaskOrchestrationExecutor( + this.orchestrationFactories, + this.dataConverter, + this.maximumTimerInterval, + logger, + this.appId); + TaskActivityExecutor taskActivityExecutor = new TaskActivityExecutor( + this.activityFactories, + this.dataConverter, + logger); + + while (true) { + try { + OrchestratorService.GetWorkItemsRequest getWorkItemsRequest = OrchestratorService.GetWorkItemsRequest + .newBuilder().build(); + Iterator workItemStream = this.sidecarClient.getWorkItems(getWorkItemsRequest); + while (workItemStream.hasNext()) { + OrchestratorService.WorkItem workItem = workItemStream.next(); + OrchestratorService.WorkItem.RequestCase requestType = workItem.getRequestCase(); + if (requestType == OrchestratorService.WorkItem.RequestCase.ORCHESTRATORREQUEST) { + OrchestratorService.OrchestratorRequest orchestratorRequest = workItem.getOrchestratorRequest(); + logger.log(Level.FINEST, + String.format("Processing orchestrator request for instance: {0}", + orchestratorRequest.getInstanceId())); + + // TODO: Error handling + this.workerPool.submit(() -> { + TaskOrchestratorResult taskOrchestratorResult = taskOrchestrationExecutor.execute( + orchestratorRequest.getPastEventsList(), + orchestratorRequest.getNewEventsList()); + + OrchestratorService.OrchestratorResponse response = OrchestratorService.OrchestratorResponse.newBuilder() + .setInstanceId(orchestratorRequest.getInstanceId()) + .addAllActions(taskOrchestratorResult.getActions()) + .setCustomStatus(StringValue.of(taskOrchestratorResult.getCustomStatus())) + .setCompletionToken(workItem.getCompletionToken()) + .build(); + + try { + this.sidecarClient.completeOrchestratorTask(response); + logger.log(Level.FINEST, + "Completed orchestrator request for instance: {0}", + orchestratorRequest.getInstanceId()); + } catch (StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.UNAVAILABLE) { + logger.log(Level.WARNING, + "The sidecar at address {0} is unavailable while completing the orchestrator task.", + this.getSidecarAddress()); + } else if (e.getStatus().getCode() == Status.Code.CANCELLED) { + logger.log(Level.WARNING, + "Durable Task worker has disconnected from {0} while completing the orchestrator task.", + this.getSidecarAddress()); + } else { + logger.log(Level.WARNING, + "Unexpected failure completing the orchestrator task at {0}.", + this.getSidecarAddress()); + } + } + }); + } else if (requestType == OrchestratorService.WorkItem.RequestCase.ACTIVITYREQUEST) { + OrchestratorService.ActivityRequest activityRequest = workItem.getActivityRequest(); + logger.log(Level.FINEST, + String.format("Processing activity request: %s for instance: %s}", + activityRequest.getName(), + activityRequest.getOrchestrationInstance().getInstanceId())); + + // TODO: Error handling + this.workerPool.submit(() -> { + String output = null; + TaskFailureDetails failureDetails = null; + try { + output = taskActivityExecutor.execute( + activityRequest.getName(), + activityRequest.getInput().getValue(), + activityRequest.getTaskExecutionId(), + activityRequest.getTaskId()); + } catch (Throwable e) { + failureDetails = TaskFailureDetails.newBuilder() + .setErrorType(e.getClass().getName()) + .setErrorMessage(e.getMessage()) + .setStackTrace(StringValue.of(FailureDetails.getFullStackTrace(e))) + .build(); + } + + OrchestratorService.ActivityResponse.Builder responseBuilder = OrchestratorService.ActivityResponse + .newBuilder() + .setInstanceId(activityRequest.getOrchestrationInstance().getInstanceId()) + .setTaskId(activityRequest.getTaskId()) + .setCompletionToken(workItem.getCompletionToken()); + + if (output != null) { + responseBuilder.setResult(StringValue.of(output)); + } + + if (failureDetails != null) { + responseBuilder.setFailureDetails(failureDetails); + } + + try { + this.sidecarClient.completeActivityTask(responseBuilder.build()); + } catch (StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.UNAVAILABLE) { + logger.log(Level.WARNING, + "The sidecar at address {0} is unavailable while completing the activity task.", + this.getSidecarAddress()); + } else if (e.getStatus().getCode() == Status.Code.CANCELLED) { + logger.log(Level.WARNING, + "Durable Task worker has disconnected from {0} while completing the activity task.", + this.getSidecarAddress()); + } else { + logger.log(Level.WARNING, "Unexpected failure completing the activity task at {0}.", + this.getSidecarAddress()); + } + } + }); + } else if (requestType == OrchestratorService.WorkItem.RequestCase.HEALTHPING) { + // No-op + } else { + logger.log(Level.WARNING, + "Received and dropped an unknown '{0}' work-item from the sidecar.", + requestType); + } + } + } catch (StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.UNAVAILABLE) { + logger.log(Level.INFO, "The sidecar at address {0} is unavailable. Will continue retrying.", + this.getSidecarAddress()); + } else if (e.getStatus().getCode() == Status.Code.CANCELLED) { + logger.log(Level.INFO, "Durable Task worker has disconnected from {0}.", this.getSidecarAddress()); + } else { + logger.log(Level.WARNING, + String.format("Unexpected failure connecting to %s", this.getSidecarAddress()), e); + } + + // Retry after 5 seconds + try { + Thread.sleep(5000); + } catch (InterruptedException ex) { + break; + } + } + } + } + + /** + * Stops the current worker's listen loop, preventing any new orchestrator or + * activity events from being processed. + */ + public void stop() { + this.close(); + } + + private void closeSideCarChannel() { + if (this.managedSidecarChannel != null) { + try { + this.managedSidecarChannel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // Best effort. Also note that AutoClose documentation recommends NOT having + // close() methods throw InterruptedException: + // https://docs.oracle.com/javase/7/docs/api/java/lang/AutoCloseable.html + } + } + } + + private void shutDownWorkerPool() { + if (this.isExecutorServiceManaged) { + if (!this.isNormalShutdown) { + logger.log(Level.WARNING, + "ExecutorService shutdown initiated unexpectedly. No new tasks will be accepted"); + } + + this.workerPool.shutdown(); + try { + if (!this.workerPool.awaitTermination(60, TimeUnit.SECONDS)) { + this.workerPool.shutdownNow(); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + } + + private String getSidecarAddress() { + return this.sidecarClient.getChannel().authority(); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorkerBuilder.java b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorkerBuilder.java new file mode 100644 index 000000000..0d3ebf227 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/DurableTaskGrpcWorkerBuilder.java @@ -0,0 +1,164 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import io.grpc.Channel; + +import java.time.Duration; +import java.util.HashMap; +import java.util.concurrent.ExecutorService; + +/** + * Builder object for constructing customized {@link DurableTaskGrpcWorker} instances. + * + */ +public final class DurableTaskGrpcWorkerBuilder { + final HashMap orchestrationFactories = new HashMap<>(); + final HashMap activityFactories = new HashMap<>(); + int port; + Channel channel; + DataConverter dataConverter; + Duration maximumTimerInterval; + ExecutorService executorService; + String appId; // App ID for cross-app routing + + /** + * Adds an orchestration factory to be used by the constructed {@link DurableTaskGrpcWorker}. + * + * @param factory an orchestration factory to be used by the constructed {@link DurableTaskGrpcWorker} + * @return this builder object + */ + public DurableTaskGrpcWorkerBuilder addOrchestration(TaskOrchestrationFactory factory) { + String key = factory.getName(); + if (key == null || key.length() == 0) { + throw new IllegalArgumentException("A non-empty task orchestration name is required."); + } + + if (this.orchestrationFactories.containsKey(key)) { + throw new IllegalArgumentException( + String.format("A task orchestration factory named %s is already registered.", key)); + } + + this.orchestrationFactories.put(key, factory); + return this; + } + + /** + * Adds an activity factory to be used by the constructed {@link DurableTaskGrpcWorker}. + * + * @param factory an activity factory to be used by the constructed {@link DurableTaskGrpcWorker} + * @return this builder object + */ + public DurableTaskGrpcWorkerBuilder addActivity(TaskActivityFactory factory) { + // TODO: Input validation + String key = factory.getName(); + if (key == null || key.length() == 0) { + throw new IllegalArgumentException("A non-empty task activity name is required."); + } + + if (this.activityFactories.containsKey(key)) { + throw new IllegalArgumentException( + String.format("A task activity factory named %s is already registered.", key)); + } + + this.activityFactories.put(key, factory); + return this; + } + + /** + * Sets the gRPC channel to use for communicating with the sidecar process. + * + *

This builder method allows you to provide your own gRPC channel for communicating with the Durable Task sidecar + * endpoint. Channels provided using this method won't be closed when the worker is closed. + * Rather, the caller remains responsible for shutting down the channel after disposing the worker.

+ * + *

If not specified, a gRPC channel will be created automatically for each constructed + * {@link DurableTaskGrpcWorker}.

+ * + * @param channel the gRPC channel to use + * @return this builder object + */ + public DurableTaskGrpcWorkerBuilder grpcChannel(Channel channel) { + this.channel = channel; + return this; + } + + /** + * Sets the gRPC endpoint port to connect to. If not specified, the default Durable Task port number will be used. + * + * @param port the gRPC endpoint port to connect to + * @return this builder object + */ + public DurableTaskGrpcWorkerBuilder port(int port) { + this.port = port; + return this; + } + + /** + * Sets the {@link DataConverter} to use for converting serializable data payloads. + * + * @param dataConverter the {@link DataConverter} to use for converting serializable data payloads + * @return this builder object + */ + public DurableTaskGrpcWorkerBuilder dataConverter(DataConverter dataConverter) { + this.dataConverter = dataConverter; + return this; + } + + /** + * Sets the maximum timer interval. If not specified, the default maximum timer interval duration will be used. + * The default maximum timer interval duration is 3 days. + * + * @param maximumTimerInterval the maximum timer interval + * @return this builder object + */ + public DurableTaskGrpcWorkerBuilder maximumTimerInterval(Duration maximumTimerInterval) { + this.maximumTimerInterval = maximumTimerInterval; + return this; + } + + /** + * Sets the executor service that will be used to execute threads. + * + * @param executorService {@link ExecutorService}. + * @return this builder object. + */ + public DurableTaskGrpcWorkerBuilder withExecutorService(ExecutorService executorService) { + this.executorService = executorService; + return this; + } + + /** + * Sets the app ID for cross-app workflow routing. + * + *

This app ID is used to identify this worker in cross-app routing scenarios. + * It should match the app ID configured in the Dapr sidecar.

+ * + * @param appId the app ID for this worker + * @return this builder object + */ + public DurableTaskGrpcWorkerBuilder appId(String appId) { + this.appId = appId; + return this; + } + + /** + * Initializes a new {@link DurableTaskGrpcWorker} object with the settings specified in the current builder object. + * + * @return a new {@link DurableTaskGrpcWorker} object + */ + public DurableTaskGrpcWorker build() { + return new DurableTaskGrpcWorker(this); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/FailureDetails.java b/durabletask-client/src/main/java/io/dapr/durabletask/FailureDetails.java new file mode 100644 index 000000000..f5d9d834e --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/FailureDetails.java @@ -0,0 +1,145 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import com.google.protobuf.StringValue; +import io.dapr.durabletask.implementation.protobuf.OrchestratorService.TaskFailureDetails; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Class that represents the details of a task failure. + * + *

In most cases, failures are caused by unhandled exceptions in activity or orchestrator code, in which case + * instances of this class will expose the details of the exception. However, it's also possible that other types + * of errors could result in task failures, in which case there may not be any exception-specific information.

+ */ +public final class FailureDetails { + private final String errorType; + private final String errorMessage; + private final String stackTrace; + private final boolean isNonRetriable; + + FailureDetails( + String errorType, + @Nullable String errorMessage, + @Nullable String errorDetails, + boolean isNonRetriable) { + this.errorType = errorType; + this.stackTrace = errorDetails; + + // Error message can be null for things like NullPointerException but the gRPC contract doesn't allow null + this.errorMessage = errorMessage != null ? errorMessage : ""; + this.isNonRetriable = isNonRetriable; + } + + FailureDetails(Exception exception) { + this(exception.getClass().getName(), exception.getMessage(), getFullStackTrace(exception), false); + } + + FailureDetails(TaskFailureDetails proto) { + this(proto.getErrorType(), + proto.getErrorMessage(), + proto.getStackTrace().getValue(), + proto.getIsNonRetriable()); + } + + /** + * Gets the exception class name if the failure was caused by an unhandled exception. Otherwise, gets a symbolic + * name that describes the general type of error that was encountered. + * + * @return the error type as a {@code String} value + */ + @Nonnull + public String getErrorType() { + return this.errorType; + } + + /** + * Gets a summary description of the error that caused this failure. If the failure was caused by an exception, the + * exception message is returned. + * + * @return a summary description of the error + */ + @Nonnull + public String getErrorMessage() { + return this.errorMessage; + } + + /** + * Gets the stack trace of the exception that caused this failure, or {@code null} if the failure was caused by + * a non-exception error. + * + * @return the stack trace of the failure exception or {@code null} if the failure was not caused by an exception + */ + @Nullable + public String getStackTrace() { + return this.stackTrace; + } + + /** + * Returns {@code true} if the failure doesn't permit retries, otherwise {@code false}. + * + * @return {@code true} if the failure doesn't permit retries, otherwise {@code false}. + */ + public boolean isNonRetriable() { + return this.isNonRetriable; + } + + /** + * Returns {@code true} if the task failure was provided by the specified exception type, otherwise {@code false}. + * + *

This method allows checking if a task failed due to a specific exception type by attempting to load the class + * specified in {@link #getErrorType()}. If the exception class cannot be loaded for any reason, this method will + * return {@code false}. Base types are supported by this method, as shown in the following example:

+ *
{@code
+   * boolean isRuntimeException = failureDetails.isCausedBy(RuntimeException.class);
+   * }
+ * + * @param exceptionClass the class representing the exception type to test + * @return {@code true} if the task failure was provided by the specified exception type, otherwise {@code false} + */ + public boolean isCausedBy(Class exceptionClass) { + String actualClassName = this.getErrorType(); + try { + // Try using reflection to load the failure's class type and see if it's a subtype of the specified + // exception. For example, this should always succeed if exceptionClass is System.Exception. + Class actualExceptionClass = Class.forName(actualClassName); + return exceptionClass.isAssignableFrom(actualExceptionClass); + } catch (ClassNotFoundException ex) { + // Can't load the class and thus can't tell if it's related + return false; + } + } + + static String getFullStackTrace(Throwable e) { + StackTraceElement[] elements = e.getStackTrace(); + + // Plan for 256 characters per stack frame (which is likely on the high-end) + StringBuilder sb = new StringBuilder(elements.length * 256); + for (StackTraceElement element : elements) { + sb.append("\tat ").append(element.toString()).append(System.lineSeparator()); + } + return sb.toString(); + } + + TaskFailureDetails toProto() { + return TaskFailureDetails.newBuilder() + .setErrorType(this.getErrorType()) + .setErrorMessage(this.getErrorMessage()) + .setStackTrace(StringValue.of(this.getStackTrace() != null ? this.getStackTrace() : "")) + .build(); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/Helpers.java b/durabletask-client/src/main/java/io/dapr/durabletask/Helpers.java new file mode 100644 index 000000000..265bb0ab0 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/Helpers.java @@ -0,0 +1,77 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.time.Duration; + +final class Helpers { + static final Duration maxDuration = Duration.ofSeconds(Long.MAX_VALUE, 999999999L); + + static @Nonnull V throwIfArgumentNull(@Nullable V argValue, String argName) { + if (argValue == null) { + throw new IllegalArgumentException("The argument '" + argName + "' was null."); + } + + return argValue; + } + + static @Nonnull String throwIfArgumentNullOrWhiteSpace(String argValue, String argName) { + throwIfArgumentNull(argValue, argName); + if (argValue.trim().length() == 0) { + throw new IllegalArgumentException("The argument '" + argName + "' was empty or contained only whitespace."); + } + + return argValue; + } + + static void throwIfOrchestratorComplete(boolean isComplete) { + if (isComplete) { + throw new IllegalStateException("The orchestrator has already completed"); + } + } + + static boolean isInfiniteTimeout(Duration timeout) { + return timeout == null || timeout.isNegative() || timeout.equals(maxDuration); + } + + static double powExact(double base, double exponent) throws ArithmeticException { + if (base == 0.0) { + return 0.0; + } + + double result = Math.pow(base, exponent); + + if (result == Double.POSITIVE_INFINITY) { + throw new ArithmeticException("Double overflow resulting in POSITIVE_INFINITY"); + } else if (result == Double.NEGATIVE_INFINITY) { + throw new ArithmeticException("Double overflow resulting in NEGATIVE_INFINITY"); + } else if (Double.compare(-0.0f, result) == 0) { + throw new ArithmeticException("Double overflow resulting in negative zero"); + } else if (Double.compare(+0.0f, result) == 0) { + throw new ArithmeticException("Double overflow resulting in positive zero"); + } + + return result; + } + + static boolean isNullOrEmpty(String s) { + return s == null || s.isEmpty(); + } + + // Cannot be instantiated + private Helpers() { + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/JacksonDataConverter.java b/durabletask-client/src/main/java/io/dapr/durabletask/JacksonDataConverter.java new file mode 100644 index 000000000..29912aa3f --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/JacksonDataConverter.java @@ -0,0 +1,58 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; + +/** + * An implementation of {@link DataConverter} that uses Jackson APIs for data serialization. + */ +public final class JacksonDataConverter implements DataConverter { + // Static singletons are recommended by the Jackson documentation + private static final ObjectMapper jsonObjectMapper = JsonMapper.builder() + .findAndAddModules() + .build(); + + @Override + public String serialize(Object value) { + if (value == null) { + return null; + } + + try { + return jsonObjectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new DataConverterException( + String.format("Failed to serialize argument of type '%s'. Detailed error message: %s", + value.getClass().getName(), e.getMessage()), + e); + } + } + + @Override + public T deserialize(String jsonText, Class targetType) { + if (jsonText == null || jsonText.length() == 0 || targetType == Void.class) { + return null; + } + + try { + return jsonObjectMapper.readValue(jsonText, targetType); + } catch (JsonProcessingException e) { + throw new DataConverterException(String.format("Failed to deserialize the JSON text to %s. " + + "Detailed error message: %s", targetType.getName(), e.getMessage()), e); + } + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/NewOrchestrationInstanceOptions.java b/durabletask-client/src/main/java/io/dapr/durabletask/NewOrchestrationInstanceOptions.java new file mode 100644 index 000000000..32639e41d --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/NewOrchestrationInstanceOptions.java @@ -0,0 +1,147 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import java.time.Instant; + +/** + * Options for starting a new instance of an orchestration. + */ +public final class NewOrchestrationInstanceOptions { + private String version; + private String instanceId; + private Object input; + private Instant startTime; + private String appID; // Target app ID for cross-app workflow routing + + /** + * Default constructor for the {@link NewOrchestrationInstanceOptions} class. + */ + public NewOrchestrationInstanceOptions() { + } + + /** + * Sets the version of the orchestration to start. + * + * @param version the user-defined version of the orchestration + * @return this {@link NewOrchestrationInstanceOptions} object + */ + public NewOrchestrationInstanceOptions setVersion(String version) { + this.version = version; + return this; + } + + /** + * Sets the instance ID of the orchestration to start. + * If no instance ID is configured, the orchestration will be created with a randomly generated instance ID. + * + * @param instanceId the ID of the new orchestration instance + * @return this {@link NewOrchestrationInstanceOptions} object + */ + public NewOrchestrationInstanceOptions setInstanceId(String instanceId) { + this.instanceId = instanceId; + return this; + } + + /** + * Sets the input of the orchestration to start. + * There are no restrictions on the type of inputs that can be used except that they must be serializable using + * the {@link DataConverter} that was configured for the {@link DurableTaskClient} at creation time. + * + * @param input the input of the new orchestration instance + * @return this {@link NewOrchestrationInstanceOptions} object + */ + public NewOrchestrationInstanceOptions setInput(Object input) { + this.input = input; + return this; + } + + /** + * Sets the start time of the new orchestration instance. + * By default, new orchestration instances start executing immediately. This method can be used + * to start them at a specific time in the future. + * + * @param startTime the start time of the new orchestration instance + * @return this {@link NewOrchestrationInstanceOptions} object + */ + public NewOrchestrationInstanceOptions setStartTime(Instant startTime) { + this.startTime = startTime; + return this; + } + + /** + * Sets the target app ID for cross-app workflow routing. + * + * @param appID the target app ID for cross-app routing + * @return this {@link NewOrchestrationInstanceOptions} object + */ + public NewOrchestrationInstanceOptions setAppID(String appID) { + this.appID = appID; + return this; + } + + /** + * Gets the user-specified version of the new orchestration. + * + * @return the user-specified version of the new orchestration. + */ + public String getVersion() { + return this.version; + } + + /** + * Gets the instance ID of the new orchestration. + * + * @return the instance ID of the new orchestration. + */ + public String getInstanceId() { + return this.instanceId; + } + + /** + * Gets the input of the new orchestration. + * + * @return the input of the new orchestration. + */ + public Object getInput() { + return this.input; + } + + /** + * Gets the configured start time of the new orchestration instance. + * + * @return the configured start time of the new orchestration instance. + */ + public Instant getStartTime() { + return this.startTime; + } + + /** + * Gets the configured target app ID for cross-app workflow routing. + * + * @return the configured target app ID + */ + public String getAppID() { + return this.appID; + } + + /** + * Checks if an app ID is configured for cross-app routing. + * + * @return true if an app ID is configured, false otherwise + */ + public boolean hasAppID() { + return this.appID != null && !this.appID.isEmpty(); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/NonDeterministicOrchestratorException.java b/durabletask-client/src/main/java/io/dapr/durabletask/NonDeterministicOrchestratorException.java new file mode 100644 index 000000000..101e6bd04 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/NonDeterministicOrchestratorException.java @@ -0,0 +1,20 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +final class NonDeterministicOrchestratorException extends RuntimeException { + public NonDeterministicOrchestratorException(String message) { + super(message); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationMetadata.java b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationMetadata.java new file mode 100644 index 000000000..a0565ba63 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationMetadata.java @@ -0,0 +1,283 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import io.dapr.durabletask.implementation.protobuf.OrchestratorService; +import io.dapr.durabletask.implementation.protobuf.OrchestratorService.OrchestrationState; + +import java.time.Instant; + +import static io.dapr.durabletask.Helpers.isNullOrEmpty; + +/** + * Represents a snapshot of an orchestration instance's current state, including metadata. + * + *

Instances of this class are produced by methods in the {@link DurableTaskClient} class, such as + * {@link DurableTaskClient#getInstanceMetadata}, {@link DurableTaskClient#waitForInstanceStart} and + * {@link DurableTaskClient#waitForInstanceCompletion}.

+ */ +public final class OrchestrationMetadata { + private final DataConverter dataConverter; + private final boolean requestedInputsAndOutputs; + + private final String name; + private final String instanceId; + private final OrchestrationRuntimeStatus runtimeStatus; + private final Instant createdAt; + private final Instant lastUpdatedAt; + private final String serializedInput; + private final String serializedOutput; + private final String serializedCustomStatus; + private final FailureDetails failureDetails; + + OrchestrationMetadata( + OrchestratorService.GetInstanceResponse fetchResponse, + DataConverter dataConverter, + boolean requestedInputsAndOutputs) { + this(fetchResponse.getOrchestrationState(), dataConverter, requestedInputsAndOutputs); + } + + OrchestrationMetadata( + OrchestrationState state, + DataConverter dataConverter, + boolean requestedInputsAndOutputs) { + this.dataConverter = dataConverter; + this.requestedInputsAndOutputs = requestedInputsAndOutputs; + + this.name = state.getName(); + this.instanceId = state.getInstanceId(); + this.runtimeStatus = OrchestrationRuntimeStatus.fromProtobuf(state.getOrchestrationStatus()); + this.createdAt = DataConverter.getInstantFromTimestamp(state.getCreatedTimestamp()); + this.lastUpdatedAt = DataConverter.getInstantFromTimestamp(state.getLastUpdatedTimestamp()); + this.serializedInput = state.getInput().getValue(); + this.serializedOutput = state.getOutput().getValue(); + this.serializedCustomStatus = state.getCustomStatus().getValue(); + this.failureDetails = new FailureDetails(state.getFailureDetails()); + } + + /** + * Gets the name of the orchestration. + * + * @return the name of the orchestration + */ + public String getName() { + return this.name; + } + + /** + * Gets the unique ID of the orchestration instance. + * + * @return the unique ID of the orchestration instance + */ + public String getInstanceId() { + return this.instanceId; + } + + /** + * Gets the current runtime status of the orchestration instance at the time this object was fetched. + * + * @return the current runtime status of the orchestration instance at the time this object was fetched + */ + public OrchestrationRuntimeStatus getRuntimeStatus() { + return this.runtimeStatus; + } + + /** + * Gets the orchestration instance's creation time in UTC. + * + * @return the orchestration instance's creation time in UTC + */ + public Instant getCreatedAt() { + return this.createdAt; + } + + /** + * Gets the orchestration instance's last updated time in UTC. + * + * @return the orchestration instance's last updated time in UTC + */ + public Instant getLastUpdatedAt() { + return this.lastUpdatedAt; + } + + /** + * Gets the orchestration instance's serialized input, if any, as a string value. + * + * @return the orchestration instance's serialized input or {@code null} + */ + public String getSerializedInput() { + return this.serializedInput; + } + + /** + * Gets the orchestration instance's serialized output, if any, as a string value. + * + * @return the orchestration instance's serialized output or {@code null} + */ + public String getSerializedOutput() { + return this.serializedOutput; + } + + /** + * Gets the failure details, if any, for the failed orchestration instance. + * + *

This method returns data only if the orchestration is in the {@link OrchestrationRuntimeStatus#FAILED} state, + * and only if this instance metadata was fetched with the option to include output data.

+ * + * @return the failure details of the failed orchestration instance or {@code null} + */ + public FailureDetails getFailureDetails() { + return this.failureDetails; + } + + /** + * Gets a value indicating whether the orchestration instance was running at the time this object was fetched. + * + * @return {@code true} if the orchestration existed and was in a running state; otherwise {@code false} + */ + public boolean isRunning() { + return isInstanceFound() && this.runtimeStatus == OrchestrationRuntimeStatus.RUNNING; + } + + /** + * Gets a value indicating whether the orchestration instance was completed at the time this object was fetched. + * + *

An orchestration instance is considered completed when its runtime status value is + * {@link OrchestrationRuntimeStatus#COMPLETED}, {@link OrchestrationRuntimeStatus#FAILED}, or + * {@link OrchestrationRuntimeStatus#TERMINATED}.

+ * + * @return {@code true} if the orchestration was in a terminal state; otherwise {@code false} + */ + public boolean isCompleted() { + return + this.runtimeStatus == OrchestrationRuntimeStatus.COMPLETED + || this.runtimeStatus == OrchestrationRuntimeStatus.FAILED + || this.runtimeStatus == OrchestrationRuntimeStatus.TERMINATED; + } + + /** + * Deserializes the orchestration's input into an object of the specified type. + * + *

Deserialization is performed using the {@link DataConverter} that was configured on + * the {@link DurableTaskClient} object that created this orchestration metadata object.

+ * + * @param type the class associated with the type to deserialize the input data into + * @param the type to deserialize the input data into + * @return the deserialized input value + * @throws IllegalStateException if the metadata was fetched without the option to read inputs and outputs + */ + public T readInputAs(Class type) { + return this.readPayloadAs(type, this.serializedInput); + } + + /** + * Deserializes the orchestration's output into an object of the specified type. + * + *

Deserialization is performed using the {@link DataConverter} that was configured on + * the {@link DurableTaskClient} object that created this orchestration metadata object.

+ * + * @param type the class associated with the type to deserialize the output data into + * @param the type to deserialize the output data into + * @return the deserialized input value + * @throws IllegalStateException if the metadata was fetched without the option to read inputs and outputs + */ + public T readOutputAs(Class type) { + return this.readPayloadAs(type, this.serializedOutput); + } + + /** + * Deserializes the orchestration's custom status into an object of the specified type. + * + *

Deserialization is performed using the {@link DataConverter} that was configured on + * the {@link DurableTaskClient} object that created this orchestration metadata object.

+ * + * @param type the class associated with the type to deserialize the custom status data into + * @param the type to deserialize the custom status data into + * @return the deserialized input value + * @throws IllegalStateException if the metadata was fetched without the option to read inputs and outputs + */ + public T readCustomStatusAs(Class type) { + return this.readPayloadAs(type, this.serializedCustomStatus); + } + + /** + * Returns {@code true} if the orchestration has a non-empty custom status value; otherwise {@code false}. + * + *

This method will always return {@code false} if the metadata was fetched without the option to read inputs and + * outputs.

+ * + * @return {@code true} if the orchestration has a non-empty custom status value; otherwise {@code false} + */ + public boolean isCustomStatusFetched() { + return this.serializedCustomStatus != null && !this.serializedCustomStatus.isEmpty(); + } + + private T readPayloadAs(Class type, String payload) { + if (!this.requestedInputsAndOutputs) { + throw new IllegalStateException("This method can only be used when instance metadata is fetched with the option " + + "to include input and output data."); + } + + // Note that the Java gRPC implementation converts null protobuf strings into empty Java strings + if (payload == null || payload.isEmpty()) { + return null; + } + + return this.dataConverter.deserialize(payload, type); + } + + /** + * Generates a user-friendly string representation of the current metadata object. + * + * @return a user-friendly string representation of the current metadata object + */ + @Override + public String toString() { + String baseString = String.format( + "[Name: '%s', ID: '%s', RuntimeStatus: %s, CreatedAt: %s, LastUpdatedAt: %s", + this.name, + this.instanceId, + this.runtimeStatus, + this.createdAt, + this.lastUpdatedAt); + StringBuilder sb = new StringBuilder(baseString); + if (this.serializedInput != null) { + sb.append(", Input: '").append(getTrimmedPayload(this.serializedInput)).append('\''); + } + + if (this.serializedOutput != null) { + sb.append(", Output: '").append(getTrimmedPayload(this.serializedOutput)).append('\''); + } + + return sb.append(']').toString(); + } + + private static String getTrimmedPayload(String payload) { + int maxLength = 50; + if (payload.length() > maxLength) { + return payload.substring(0, maxLength) + "..."; + } + + return payload; + } + + /** + * Returns {@code true} if an orchestration instance with this ID was found; otherwise {@code false}. + * + * @return {@code true} if an orchestration instance with this ID was found; otherwise {@code false} + */ + public boolean isInstanceFound() { + return !(isNullOrEmpty(this.name) && isNullOrEmpty(this.instanceId)); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationRunner.java b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationRunner.java new file mode 100644 index 000000000..22b215460 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationRunner.java @@ -0,0 +1,169 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.StringValue; +import io.dapr.durabletask.implementation.protobuf.OrchestratorService; + +import java.time.Duration; +import java.util.Base64; +import java.util.HashMap; +import java.util.logging.Logger; + +/** + * Helper class for invoking orchestrations directly, without constructing a {@link DurableTaskGrpcWorker} object. + * + *

This static class can be used to execute orchestration logic directly. In order to use it for this purpose, the + * caller must provide orchestration state as serialized protobuf bytes.

+ */ +public final class OrchestrationRunner { + private static final Logger logger = Logger.getLogger(OrchestrationRunner.class.getPackage().getName()); + private static final Duration DEFAULT_MAXIMUM_TIMER_INTERVAL = Duration.ofDays(3); + + private OrchestrationRunner() { + } + + /** + * Loads orchestration history from {@code base64EncodedOrchestratorRequest} and uses it to execute the + * orchestrator function code pointed to by {@code orchestratorFunc}. + * + * @param base64EncodedOrchestratorRequest the base64-encoded protobuf payload representing an orchestrator execution + * request + * @param orchestratorFunc a function that implements the orchestrator logic + * @param the type of the orchestrator function output, which must be serializable + * to JSON + * @return a base64-encoded protobuf payload of orchestrator actions to be interpreted by the external + * orchestration engine + * @throws IllegalArgumentException if either parameter is {@code null} or + * if {@code base64EncodedOrchestratorRequest} is not valid base64-encoded protobuf + */ + public static String loadAndRun( + String base64EncodedOrchestratorRequest, + OrchestratorFunction orchestratorFunc) { + // Example string: CiBhOTMyYjdiYWM5MmI0MDM5YjRkMTYxMDIwNzlmYTM1YSIaCP///////////wESCwi254qRBhDk+rgocgAicgj////// + // ///8BEgwIs+eKkQYQzMXjnQMaVwoLSGVsbG9DaXRpZXMSACJGCiBhOTMyYjdiYWM5MmI0MDM5YjRkMTYxMDIwNzlmYTM1YRIiCiA3ODEwOTA + // 2N2Q4Y2Q0ODg1YWU4NjQ0OTNlMmRlMGQ3OA== + byte[] decodedBytes = Base64.getDecoder().decode(base64EncodedOrchestratorRequest); + byte[] resultBytes = loadAndRun(decodedBytes, orchestratorFunc); + return Base64.getEncoder().encodeToString(resultBytes); + } + + /** + * Loads orchestration history from {@code orchestratorRequestBytes} and uses it to execute the + * orchestrator function code pointed to by {@code orchestratorFunc}. + * + * @param orchestratorRequestBytes the protobuf payload representing an orchestrator execution request + * @param orchestratorFunc a function that implements the orchestrator logic + * @param the type of the orchestrator function output, which must be serializable to JSON + * @return a protobuf-encoded payload of orchestrator actions to be interpreted by the external orchestration engine + * @throws IllegalArgumentException if either parameter is {@code null} or if {@code orchestratorRequestBytes} is + * not valid protobuf + */ + public static byte[] loadAndRun( + byte[] orchestratorRequestBytes, + OrchestratorFunction orchestratorFunc) { + if (orchestratorFunc == null) { + throw new IllegalArgumentException("orchestratorFunc must not be null"); + } + + // Wrap the provided lambda in an anonymous TaskOrchestration + TaskOrchestration orchestration = ctx -> { + R output = orchestratorFunc.apply(ctx); + ctx.complete(output); + }; + + return loadAndRun(orchestratorRequestBytes, orchestration); + } + + /** + * Loads orchestration history from {@code base64EncodedOrchestratorRequest} and uses it to execute the + * {@code orchestration}. + * + * @param base64EncodedOrchestratorRequest the base64-encoded protobuf payload representing an orchestrator + * execution request + * @param orchestration the orchestration to execute + * @return a base64-encoded protobuf payload of orchestrator actions to be interpreted by the external + * orchestration engine + * @throws IllegalArgumentException if either parameter is {@code null} or + * if {@code base64EncodedOrchestratorRequest} is not valid base64-encoded protobuf + */ + public static String loadAndRun( + String base64EncodedOrchestratorRequest, + TaskOrchestration orchestration) { + byte[] decodedBytes = Base64.getDecoder().decode(base64EncodedOrchestratorRequest); + byte[] resultBytes = loadAndRun(decodedBytes, orchestration); + return Base64.getEncoder().encodeToString(resultBytes); + } + + /** + * Loads orchestration history from {@code orchestratorRequestBytes} and uses it to execute the + * {@code orchestration}. + * + * @param orchestratorRequestBytes the protobuf payload representing an orchestrator execution request + * @param orchestration the orchestration to execute + * @return a protobuf-encoded payload of orchestrator actions to be interpreted by the external orchestration engine + * @throws IllegalArgumentException if either parameter is {@code null} or if {@code orchestratorRequestBytes} + * is not valid protobuf + */ + public static byte[] loadAndRun(byte[] orchestratorRequestBytes, TaskOrchestration orchestration) { + if (orchestratorRequestBytes == null || orchestratorRequestBytes.length == 0) { + throw new IllegalArgumentException("triggerStateProtoBytes must not be null or empty"); + } + + if (orchestration == null) { + throw new IllegalArgumentException("orchestration must not be null"); + } + + OrchestratorService.OrchestratorRequest orchestratorRequest; + try { + orchestratorRequest = OrchestratorService.OrchestratorRequest.parseFrom(orchestratorRequestBytes); + } catch (InvalidProtocolBufferException e) { + throw new IllegalArgumentException("triggerStateProtoBytes was not valid protobuf", e); + } + + // Register the passed orchestration as the default ("*") orchestration + HashMap orchestrationFactories = new HashMap<>(); + orchestrationFactories.put("*", new TaskOrchestrationFactory() { + @Override + public String getName() { + return "*"; + } + + @Override + public TaskOrchestration create() { + return orchestration; + } + }); + + TaskOrchestrationExecutor taskOrchestrationExecutor = new TaskOrchestrationExecutor( + orchestrationFactories, + new JacksonDataConverter(), + DEFAULT_MAXIMUM_TIMER_INTERVAL, + logger, + null); // No app ID for static runner + + // TODO: Error handling + TaskOrchestratorResult taskOrchestratorResult = taskOrchestrationExecutor.execute( + orchestratorRequest.getPastEventsList(), + orchestratorRequest.getNewEventsList()); + + OrchestratorService.OrchestratorResponse response = OrchestratorService.OrchestratorResponse.newBuilder() + .setInstanceId(orchestratorRequest.getInstanceId()) + .addAllActions(taskOrchestratorResult.getActions()) + .setCustomStatus(StringValue.of(taskOrchestratorResult.getCustomStatus())) + .build(); + return response.toByteArray(); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationRuntimeStatus.java b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationRuntimeStatus.java new file mode 100644 index 000000000..1bdd33ab3 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationRuntimeStatus.java @@ -0,0 +1,118 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import io.dapr.durabletask.implementation.protobuf.OrchestratorService; + +import static io.dapr.durabletask.implementation.protobuf.OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_CANCELED; +import static io.dapr.durabletask.implementation.protobuf.OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED; +import static io.dapr.durabletask.implementation.protobuf.OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW; +import static io.dapr.durabletask.implementation.protobuf.OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED; +import static io.dapr.durabletask.implementation.protobuf.OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_PENDING; +import static io.dapr.durabletask.implementation.protobuf.OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_RUNNING; +import static io.dapr.durabletask.implementation.protobuf.OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_SUSPENDED; +import static io.dapr.durabletask.implementation.protobuf.OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_TERMINATED; + +/** + * Enum describing the runtime status of the orchestration. + */ +public enum OrchestrationRuntimeStatus { + /** + * The orchestration started running. + */ + RUNNING, + + /** + * The orchestration completed normally. + */ + COMPLETED, + + /** + * The orchestration is transitioning into a new instance. + * This status value is obsolete and exists only for compatibility reasons. + */ + CONTINUED_AS_NEW, + + /** + * The orchestration completed with an unhandled exception. + */ + FAILED, + + /** + * The orchestration canceled gracefully. + * The Canceled status is not currently used and exists only for compatibility reasons. + */ + CANCELED, + + /** + * The orchestration was abruptly terminated via a management API call. + */ + TERMINATED, + + /** + * The orchestration was scheduled but hasn't started running. + */ + PENDING, + + /** + * The orchestration is in a suspended state. + */ + SUSPENDED; + + static OrchestrationRuntimeStatus fromProtobuf(OrchestratorService.OrchestrationStatus status) { + switch (status) { + case ORCHESTRATION_STATUS_RUNNING: + return RUNNING; + case ORCHESTRATION_STATUS_COMPLETED: + return COMPLETED; + case ORCHESTRATION_STATUS_CONTINUED_AS_NEW: + return CONTINUED_AS_NEW; + case ORCHESTRATION_STATUS_FAILED: + return FAILED; + case ORCHESTRATION_STATUS_CANCELED: + return CANCELED; + case ORCHESTRATION_STATUS_TERMINATED: + return TERMINATED; + case ORCHESTRATION_STATUS_PENDING: + return PENDING; + case ORCHESTRATION_STATUS_SUSPENDED: + return SUSPENDED; + default: + throw new IllegalArgumentException(String.format("Unknown status value: %s", status)); + } + } + + static OrchestratorService.OrchestrationStatus toProtobuf(OrchestrationRuntimeStatus status) { + switch (status) { + case RUNNING: + return ORCHESTRATION_STATUS_RUNNING; + case COMPLETED: + return ORCHESTRATION_STATUS_COMPLETED; + case CONTINUED_AS_NEW: + return ORCHESTRATION_STATUS_CONTINUED_AS_NEW; + case FAILED: + return ORCHESTRATION_STATUS_FAILED; + case CANCELED: + return ORCHESTRATION_STATUS_CANCELED; + case TERMINATED: + return ORCHESTRATION_STATUS_TERMINATED; + case PENDING: + return ORCHESTRATION_STATUS_PENDING; + case SUSPENDED: + return ORCHESTRATION_STATUS_SUSPENDED; + default: + throw new IllegalArgumentException(String.format("Unknown status value: %s", status)); + } + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQuery.java b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQuery.java new file mode 100644 index 000000000..864fc37c8 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQuery.java @@ -0,0 +1,217 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import javax.annotation.Nullable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Class used for constructing orchestration metadata queries. + */ +public final class OrchestrationStatusQuery { + private List runtimeStatusList = new ArrayList<>(); + private Instant createdTimeFrom; + private Instant createdTimeTo; + private List taskHubNames = new ArrayList<>(); + private int maxInstanceCount = 100; + private String continuationToken; + private String instanceIdPrefix; + private boolean fetchInputsAndOutputs; + + /** + * Sole constructor. + */ + public OrchestrationStatusQuery() { + } + + /** + * Sets the list of runtime status values to use as a filter. Only orchestration instances that have a matching + * runtime status will be returned. The default {@code null} value will disable runtime status filtering. + * + * @param runtimeStatusList the list of runtime status values to use as a filter + * @return this query object + */ + public OrchestrationStatusQuery setRuntimeStatusList(@Nullable List runtimeStatusList) { + this.runtimeStatusList = runtimeStatusList; + return this; + } + + /** + * Include orchestration instances that were created after the specified instant. + * + * @param createdTimeFrom the minimum orchestration creation time to use as a filter or {@code null} to disable this + * filter + * @return this query object + */ + public OrchestrationStatusQuery setCreatedTimeFrom(@Nullable Instant createdTimeFrom) { + this.createdTimeFrom = createdTimeFrom; + return this; + } + + /** + * Include orchestration instances that were created before the specified instant. + * + * @param createdTimeTo the maximum orchestration creation time to use as a filter or {@code null} to disable this + * filter + * @return this query object + */ + public OrchestrationStatusQuery setCreatedTimeTo(@Nullable Instant createdTimeTo) { + this.createdTimeTo = createdTimeTo; + return this; + } + + /** + * Sets the maximum number of records that can be returned by the query. The default value is 100. + * + *

Requests may return fewer records than the specified page size, even if there are more records. + * Always check the continuation token to determine whether there are more records.

+ * + * @param maxInstanceCount the maximum number of orchestration metadata records to return + * @return this query object + */ + public OrchestrationStatusQuery setMaxInstanceCount(int maxInstanceCount) { + this.maxInstanceCount = maxInstanceCount; + return this; + } + + /** + * Include orchestration metadata records that have a matching task hub name. + * + * @param taskHubNames the task hub name to match or {@code null} to disable this filter + * @return this query object + */ + public OrchestrationStatusQuery setTaskHubNames(@Nullable List taskHubNames) { + this.taskHubNames = taskHubNames; + return this; + } + + /** + * Sets the continuation token used to continue paging through orchestration metadata results. + * + *

This should always be the continuation token value from the previous query's + * {@link OrchestrationStatusQueryResult} result.

+ * + * @param continuationToken the continuation token from the previous query + * @return this query object + */ + public OrchestrationStatusQuery setContinuationToken(@Nullable String continuationToken) { + this.continuationToken = continuationToken; + return this; + } + + /** + * Include orchestration metadata records with the specified instance ID prefix. + * + *

For example, if there are three orchestration instances in the metadata store with IDs "Foo", "Bar", and "Baz", + * specifying a prefix value of "B" will exclude "Foo" since its ID doesn't start with "B".

+ * + * @param instanceIdPrefix the instance ID prefix filter value + * @return this query object + */ + public OrchestrationStatusQuery setInstanceIdPrefix(@Nullable String instanceIdPrefix) { + this.instanceIdPrefix = instanceIdPrefix; + return this; + } + + /** + * Sets whether to fetch orchestration inputs, outputs, and custom status values. The default value is {@code false}. + * + * @param fetchInputsAndOutputs {@code true} to fetch orchestration inputs, outputs, and custom status values, + * otherwise {@code false} + * @return this query object + */ + public OrchestrationStatusQuery setFetchInputsAndOutputs(boolean fetchInputsAndOutputs) { + this.fetchInputsAndOutputs = fetchInputsAndOutputs; + return this; + } + + /** + * Gets the configured runtime status filter or {@code null} if none was configured. + * + * @return the configured runtime status filter as a list of values or {@code null} if none was configured + */ + public List getRuntimeStatusList() { + return runtimeStatusList; + } + + /** + * Gets the configured minimum orchestration creation time or {@code null} if none was configured. + * + * @return the configured minimum orchestration creation time or {@code null} if none was configured + */ + @Nullable + public Instant getCreatedTimeFrom() { + return createdTimeFrom; + } + + /** + * Gets the configured maximum orchestration creation time or {@code null} if none was configured. + * + * @return the configured maximum orchestration creation time or {@code null} if none was configured + */ + @Nullable + public Instant getCreatedTimeTo() { + return createdTimeTo; + } + + /** + * Gets the configured maximum number of records that can be returned by the query. + * + * @return the configured maximum number of records that can be returned by the query + */ + public int getMaxInstanceCount() { + return maxInstanceCount; + } + + /** + * Gets the configured task hub names to match or {@code null} if none were configured. + * + * @return the configured task hub names to match or {@code null} if none were configured + */ + public List getTaskHubNames() { + return taskHubNames; + } + + /** + * Gets the configured continuation token value or {@code null} if none was configured. + * + * @return the configured continuation token value or {@code null} if none was configured + */ + @Nullable + public String getContinuationToken() { + return continuationToken; + } + + /** + * Gets the configured instance ID prefix filter value or {@code null} if none was configured. + * + * @return the configured instance ID prefix filter value or {@code null} if none was configured. + */ + @Nullable + public String getInstanceIdPrefix() { + return instanceIdPrefix; + } + + /** + * Gets the configured value that determines whether to fetch orchestration inputs, outputs, and custom status values. + * + * @return the configured value that determines whether to fetch orchestration inputs, outputs, and custom + * status values + */ + public boolean isFetchInputsAndOutputs() { + return fetchInputsAndOutputs; + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQueryResult.java b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQueryResult.java new file mode 100644 index 000000000..efb4908c1 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestrationStatusQueryResult.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import javax.annotation.Nullable; +import java.util.List; + +/** + * Class representing the results of a filtered orchestration metadata query. + * + *

Orchestration metadata can be queried with filters using the {@link DurableTaskClient#queryInstances} method.

+ */ +public final class OrchestrationStatusQueryResult { + private final List orchestrationStates; + private final String continuationToken; + + OrchestrationStatusQueryResult(List orchestrationStates, @Nullable String continuationToken) { + this.orchestrationStates = orchestrationStates; + this.continuationToken = continuationToken; + } + + /** + * Gets the list of orchestration metadata records that matched the {@link DurableTaskClient#queryInstances} query. + * + * @return the list of orchestration metadata records that matched the {@link DurableTaskClient#queryInstances} query. + */ + public List getOrchestrationState() { + return this.orchestrationStates; + } + + /** + * Gets the continuation token to use with the next query or {@code null} if no more metadata records are found. + * + *

Note that a non-null value does not always mean that there are more metadata records that can be returned by a + * query.

+ * + * @return the continuation token to use with the next query or {@code null} if no more metadata records are found. + */ + public String getContinuationToken() { + return this.continuationToken; + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/OrchestratorFunction.java b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestratorFunction.java new file mode 100644 index 000000000..a4d2f2f08 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/OrchestratorFunction.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +/** + * Functional interface for inline orchestrator functions. + * + *

See the description of {@link TaskOrchestration} for more information about how to correctly + * implement orchestrators.

+ * + * @param the type of the result returned by the function + */ +@FunctionalInterface +public interface OrchestratorFunction { + /** + * Executes an orchestrator function and returns a result to use as the orchestration output. + * + *

This functional interface is designed to support implementing orchestrators as lambda functions. It's intended + * to be very similar to {@link java.util.function.Function}, but with a signature that's specific to + * orchestrators.

+ * + * @param ctx the orchestration context, which provides access to additional context for the current orchestration + * execution + * @return the serializable output of the orchestrator function + */ + R apply(TaskOrchestrationContext ctx); +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/PurgeInstanceCriteria.java b/durabletask-client/src/main/java/io/dapr/durabletask/PurgeInstanceCriteria.java new file mode 100644 index 000000000..50260c1fc --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/PurgeInstanceCriteria.java @@ -0,0 +1,125 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import javax.annotation.Nullable; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Class used for constructing orchestration instance purge selection criteria. + */ +public final class PurgeInstanceCriteria { + + private Instant createdTimeFrom; + private Instant createdTimeTo; + private List runtimeStatusList = new ArrayList<>(); + private Duration timeout; + + /** + * Creates a new, default instance of the {@code PurgeInstanceCriteria} class. + */ + public PurgeInstanceCriteria() { + } + + /** + * Purge orchestration instances that were created after the specified instant. + * + * @param createdTimeFrom the minimum orchestration creation time to use as a selection criteria or {@code null} to + * disable this selection criteria + * @return this criteria object + */ + public PurgeInstanceCriteria setCreatedTimeFrom(Instant createdTimeFrom) { + this.createdTimeFrom = createdTimeFrom; + return this; + } + + /** + * Purge orchestration instances that were created before the specified instant. + * + * @param createdTimeTo the maximum orchestration creation time to use as a selection criteria or {@code null} to + * disable this selection criteria + * @return this criteria object + */ + public PurgeInstanceCriteria setCreatedTimeTo(Instant createdTimeTo) { + this.createdTimeTo = createdTimeTo; + return this; + } + + /** + * Sets the list of runtime status values to use as a selection criteria. Only orchestration instances that have a + * matching runtime status will be purged. An empty list is the same as selecting for all runtime status values. + * + * @param runtimeStatusList the list of runtime status values to use as a selection criteria + * @return this criteria object + */ + public PurgeInstanceCriteria setRuntimeStatusList(List runtimeStatusList) { + this.runtimeStatusList = runtimeStatusList; + return this; + } + + /** + * Sets a timeout duration for the purge operation. Setting to {@code null} will reset the timeout + * to be the default value. + * + * @param timeout the amount of time to wait for the purge instance operation to complete + * @return this criteria object + */ + public PurgeInstanceCriteria setTimeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + /** + * Gets the configured minimum orchestration creation time or {@code null} if none was configured. + * + * @return the configured minimum orchestration creation time or {@code null} if none was configured + */ + @Nullable + public Instant getCreatedTimeFrom() { + return this.createdTimeFrom; + } + + /** + * Gets the configured maximum orchestration creation time or {@code null} if none was configured. + * + * @return the configured maximum orchestration creation time or {@code null} if none was configured + */ + @Nullable + public Instant getCreatedTimeTo() { + return this.createdTimeTo; + } + + /** + * Gets the configured runtime status selection criteria. + * + * @return the configured runtime status filter as a list of values + */ + public List getRuntimeStatusList() { + return this.runtimeStatusList; + } + + /** + * Gets the configured timeout duration or {@code null} if none was configured. + * + * @return the configured timeout + */ + @Nullable + public Duration getTimeout() { + return this.timeout; + } + +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/PurgeResult.java b/durabletask-client/src/main/java/io/dapr/durabletask/PurgeResult.java new file mode 100644 index 000000000..8d3521866 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/PurgeResult.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +/** + * Class representing the results of an orchestration state purge operation. + * + *

Orchestration state can be purged using any of the {@link DurableTaskClient#purgeInstances} method overloads.

+ */ +public final class PurgeResult { + + private final int deletedInstanceCount; + + PurgeResult(int deletedInstanceCount) { + this.deletedInstanceCount = deletedInstanceCount; + } + + /** + * Gets the number of purged orchestration instances. + * + * @return the number of purged orchestration instances + */ + public int getDeletedInstanceCount() { + return this.deletedInstanceCount; + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/RetryContext.java b/durabletask-client/src/main/java/io/dapr/durabletask/RetryContext.java new file mode 100644 index 000000000..620e02c7d --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/RetryContext.java @@ -0,0 +1,79 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import java.time.Duration; + +/** + * Context data that's provided to {@link RetryHandler} implementations. + */ +public final class RetryContext { + private final TaskOrchestrationContext orchestrationContext; + private final int lastAttemptNumber; + private final FailureDetails lastFailure; + private final Duration totalRetryTime; + + RetryContext( + TaskOrchestrationContext orchestrationContext, + int lastAttemptNumber, + FailureDetails lastFailure, + Duration totalRetryTime) { + this.orchestrationContext = orchestrationContext; + this.lastAttemptNumber = lastAttemptNumber; + this.lastFailure = lastFailure; + this.totalRetryTime = totalRetryTime; + } + + /** + * Gets the context of the current orchestration. + * + *

The orchestration context can be used in retry handlers to schedule timers (via the + * {@link TaskOrchestrationContext#createTimer} methods) for implementing delays between retries. It can also be + * used to implement time-based retry logic by using the {@link TaskOrchestrationContext#getCurrentInstant} method. + *

+ * + * @return the context of the parent orchestration + */ + public TaskOrchestrationContext getOrchestrationContext() { + return this.orchestrationContext; + } + + /** + * Gets the details of the previous task failure, including the exception type, message, and callstack. + * + * @return the details of the previous task failure + */ + public FailureDetails getLastFailure() { + return this.lastFailure; + } + + /** + * Gets the previous retry attempt number. This number starts at 1 and increments each time the retry handler + * is invoked for a particular task failure. + * + * @return the previous retry attempt number + */ + public int getLastAttemptNumber() { + return this.lastAttemptNumber; + } + + /** + * Gets the total amount of time spent in a retry loop for the current task. + * + * @return the total amount of time spent in a retry loop for the current task + */ + public Duration getTotalRetryTime() { + return this.totalRetryTime; + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/RetryHandler.java b/durabletask-client/src/main/java/io/dapr/durabletask/RetryHandler.java new file mode 100644 index 000000000..ad246a0c6 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/RetryHandler.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +/** + * Functional interface for implementing custom task retry handlers. + * + *

It's important to remember that retry handler code is an extension of the orchestrator code and must + * therefore comply with all the determinism requirements of orchestrator code.

+ */ +@FunctionalInterface +public interface RetryHandler { + /** + * Invokes the retry handler logic and returns a value indicating whether to continue retrying. + * + * @param context retry context that's updated between each retry attempt + * @return {@code true} to continue retrying or {@code false} to stop retrying. + */ + boolean handle(RetryContext context); +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/RetryPolicy.java b/durabletask-client/src/main/java/io/dapr/durabletask/RetryPolicy.java new file mode 100644 index 000000000..9efd912b1 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/RetryPolicy.java @@ -0,0 +1,176 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import javax.annotation.Nullable; +import java.time.Duration; +import java.util.Objects; + +/** + * A declarative retry policy that can be configured for activity or sub-orchestration calls. + */ +public final class RetryPolicy { + + private int maxNumberOfAttempts; + private Duration firstRetryInterval; + private double backoffCoefficient = 1.0; + private Duration maxRetryInterval = Duration.ZERO; + private Duration retryTimeout = Duration.ZERO; + + /** + * Creates a new {@code RetryPolicy} object. + * + * @param maxNumberOfAttempts the maximum number of task invocation attempts; must be 1 or greater + * @param firstRetryInterval the amount of time to delay between the first and second attempt + * @throws IllegalArgumentException if {@code maxNumberOfAttempts} is zero or negative + */ + public RetryPolicy(int maxNumberOfAttempts, Duration firstRetryInterval) { + this.setMaxNumberOfAttempts(maxNumberOfAttempts); + this.setFirstRetryInterval(firstRetryInterval); + } + + /** + * Sets the maximum number of task invocation attempts; must be 1 or greater. + * + *

This value represents the number of times to attempt to execute the task. It does not represent + * the maximum number of times to retry the task. This is why the number must be 1 or greater.

+ * + * @param maxNumberOfAttempts the maximum number of attempts; must be 1 or greater + * @return this retry policy object + * @throws IllegalArgumentException if {@code maxNumberOfAttempts} is zero or negative + */ + public RetryPolicy setMaxNumberOfAttempts(int maxNumberOfAttempts) { + if (maxNumberOfAttempts <= 0) { + throw new IllegalArgumentException("The value for maxNumberOfAttempts must be greater than zero."); + } + this.maxNumberOfAttempts = maxNumberOfAttempts; + return this; + } + + /** + * Sets the amount of time to delay between the first and second attempt. + * + * @param firstRetryInterval the amount of time to delay between the first and second attempt + * @return this retry policy object + * @throws IllegalArgumentException if {@code firstRetryInterval} is {@code null}, zero, or negative. + */ + public RetryPolicy setFirstRetryInterval(Duration firstRetryInterval) { + if (firstRetryInterval == null) { + throw new IllegalArgumentException("firstRetryInterval cannot be null."); + } + if (firstRetryInterval.isZero() || firstRetryInterval.isNegative()) { + throw new IllegalArgumentException("The value for firstRetryInterval must be greater than zero."); + } + this.firstRetryInterval = firstRetryInterval; + return this; + } + + /** + * Sets the exponential backoff coefficient used to determine the delay between subsequent retries. + * Must be 1.0 or greater. + * + *

To avoid extremely long delays between retries, consider also specifying a maximum retry interval using the + * {@link #setMaxRetryInterval} method.

+ * + * @param backoffCoefficient the exponential backoff coefficient + * @return this retry policy object + * @throws IllegalArgumentException if {@code backoffCoefficient} is less than 1.0 + */ + public RetryPolicy setBackoffCoefficient(double backoffCoefficient) { + if (backoffCoefficient < 1.0) { + throw new IllegalArgumentException("The value for backoffCoefficient must be greater or equal to 1.0."); + } + this.backoffCoefficient = backoffCoefficient; + return this; + } + + /** + * Sets the maximum time to delay between attempts. + * + *

It's recommended to set a maximum retry interval whenever using a backoff coefficient that's greater than the + * default of 1.0.

+ * + * @param maxRetryInterval the maximum time to delay between attempts or {@code null} to remove the maximum retry + * interval + * @return this retry policy object + */ + public RetryPolicy setMaxRetryInterval(@Nullable Duration maxRetryInterval) { + if (maxRetryInterval != null && maxRetryInterval.compareTo(this.firstRetryInterval) < 0) { + throw new IllegalArgumentException("The value for maxRetryInterval must be greater than or equal to the value " + + "for firstRetryInterval."); + } + this.maxRetryInterval = maxRetryInterval; + return this; + } + + /** + * Sets the overall timeout for retries, regardless of the retry count. + * + * @param retryTimeout the overall timeout for retries + * @return this retry policy object + */ + public RetryPolicy setRetryTimeout(Duration retryTimeout) { + if (retryTimeout == null || retryTimeout.compareTo(this.firstRetryInterval) < 0) { + throw new IllegalArgumentException("The value for retryTimeout cannot be null and must be greater than or equal " + + "to the value for firstRetryInterval."); + } + this.retryTimeout = retryTimeout; + return this; + } + + /** + * Gets the configured maximum number of task invocation attempts. + * + * @return the configured maximum number of task invocation attempts. + */ + public int getMaxNumberOfAttempts() { + return this.maxNumberOfAttempts; + } + + /** + * Gets the configured amount of time to delay between the first and second attempt. + * + * @return the configured amount of time to delay between the first and second attempt + */ + public Duration getFirstRetryInterval() { + return this.firstRetryInterval; + } + + /** + * Gets the configured exponential backoff coefficient used to determine the delay between subsequent retries. + * + * @return the configured exponential backoff coefficient used to determine the delay between subsequent retries + */ + public double getBackoffCoefficient() { + return this.backoffCoefficient; + } + + /** + * Gets the configured maximum time to delay between attempts. + * + * @return the configured maximum time to delay between attempts + */ + public Duration getMaxRetryInterval() { + return this.maxRetryInterval; + } + + /** + * Gets the configured overall timeout for retries. + * + * @return the configured overall timeout for retries + */ + public Duration getRetryTimeout() { + return this.retryTimeout; + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/Task.java b/durabletask-client/src/main/java/io/dapr/durabletask/Task.java new file mode 100644 index 000000000..a3f331381 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/Task.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import io.dapr.durabletask.interruption.OrchestratorBlockedException; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Represents an asynchronous operation in a durable orchestration. + * + *

{@code Task} instances are created by methods on the {@link TaskOrchestrationContext} class, which is available + * in {@link TaskOrchestration} implementations. For example, scheduling an activity will return a task.

+ *
+ * Task{@literal <}int{@literal >} activityTask = ctx.callActivity("MyActivity", someInput, int.class);
+ * 
+ *

Orchestrator code uses the {@link #await()} method to block on the completion of the task and retrieve the result. + * If the task is not yet complete, the {@code await()} method will throw an {@link OrchestratorBlockedException}, which + * pauses the orchestrator's execution so that it can save its progress into durable storage and schedule any + * outstanding work. When the task is complete, the orchestrator will run again from the beginning and the next time + * the task's {@code await()} method is called, the result will be returned, or a {@link TaskFailedException} will be + * thrown if the result of the task was an unhandled exception.

+ *

Note that orchestrator code must never catch {@code OrchestratorBlockedException} because doing so can cause the + * orchestration instance to get permanently stuck.

+ * + * @param the return type of the task + */ +public abstract class Task { + final CompletableFuture future; + + Task(CompletableFuture future) { + this.future = future; + } + + /** + * Returns {@code true} if completed in any fashion: normally, with an exception, or via cancellation. + * + * @return {@code true} if completed, otherwise {@code false} + */ + public boolean isDone() { + return this.future.isDone(); + } + + /** + * Returns {@code true} if the task was cancelled. + * + * @return {@code true} if the task was cancelled, otherwise {@code false} + */ + public boolean isCancelled() { + return this.future.isCancelled(); + } + + /** + * Blocks the orchestrator until this task to complete, and then returns its result. + * + * @return the result of the task + */ + public abstract V await(); + + /** + * Returns a new {@link Task} that, when this Task completes normally, + * is executed with this Task's result as the argument to the supplied function. + * + * @param fn the function to use to compute the value of the returned Task + * @param the function's return type + * @return the new Task + */ + public abstract Task thenApply(Function fn); + + /** + * Returns a new {@link Task} that, when this Task completes normally, + * is executed with this Task's result as the argument to the supplied action. + * + * @param fn the function to use to compute the value of the returned Task + * @return the new Task + */ + public abstract Task thenAccept(Consumer fn); +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivity.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivity.java new file mode 100644 index 000000000..27e4291e9 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivity.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +/** + * Common interface for task activity implementations. + * + *

Activities are the basic unit of work in a durable task orchestration. Activities are the tasks that are + * orchestrated in the business process. For example, you might create an orchestrator to process an order. The tasks + * ay involve checking the inventory, charging the customer, and creating a shipment. Each task would be a separate + * activity. These activities may be executed serially, in parallel, or some combination of both.

+ * + *

Unlike task orchestrators, activities aren't restricted in the type of work you can do in them. Activity functions + * are frequently used to make network calls or run CPU intensive operations. An activity can also return data back to + * the orchestrator function. The Durable Task runtime guarantees that each called activity function will be executed + * at least once during an orchestration's execution.

+ * + *

Because activities only guarantee at least once execution, it's recommended that activity logic be implemented as + * idempotent whenever possible.

+ * + *

Activities are scheduled by orchestrators using one of the {@link TaskOrchestrationContext#callActivity} method + * overloads.

+ */ +@FunctionalInterface +public interface TaskActivity { + /** + * Executes the activity logic and returns a value which will be serialized and returned to the calling orchestrator. + * + * @param ctx provides information about the current activity execution, like the activity's name and the input + * data provided to it by the orchestrator. + * @return any serializable value to be returned to the calling orchestrator. + */ + Object run(TaskActivityContext ctx); +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityContext.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityContext.java new file mode 100644 index 000000000..b2043b51e --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityContext.java @@ -0,0 +1,51 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +/** + * Interface that provides {@link TaskActivity} implementations with activity context, such as an activity's name and + * its input. + */ +public interface TaskActivityContext { + /** + * Gets the name of the current task activity. + * + * @return the name of the current task activity + */ + String getName(); + + /** + * Gets the deserialized activity input. + * + * @param targetType the {@link Class} object associated with {@code T} + * @param the target type to deserialize the input into + * @return the deserialized activity input value + */ + T getInput(Class targetType); + + + /** + * Gets the execution id of the current task activity. + * + * @return the execution id of the current task activity + */ + String getTaskExecutionId(); + + /** + * Gets the task id of the current task activity. + * + * @return the task id of the current task activity + */ + int getTaskId(); +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityExecutor.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityExecutor.java new file mode 100644 index 000000000..a8ef6c67e --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityExecutor.java @@ -0,0 +1,96 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import java.util.HashMap; +import java.util.logging.Logger; + +final class TaskActivityExecutor { + private final HashMap activityFactories; + private final DataConverter dataConverter; + private final Logger logger; + + public TaskActivityExecutor( + HashMap activityFactories, + DataConverter dataConverter, + Logger logger) { + this.activityFactories = activityFactories; + this.dataConverter = dataConverter; + this.logger = logger; + } + + public String execute(String taskName, String input, String taskExecutionId, int taskId) throws Throwable { + TaskActivityFactory factory = this.activityFactories.get(taskName); + if (factory == null) { + throw new IllegalStateException( + String.format("No activity task named '%s' is registered.", taskName)); + } + + TaskActivity activity = factory.create(); + if (activity == null) { + throw new IllegalStateException( + String.format("The task factory '%s' returned a null TaskActivity object.", taskName)); + } + + TaskActivityContextImpl context = new TaskActivityContextImpl(taskName, input, taskExecutionId, taskId); + + // Unhandled exceptions are allowed to escape + Object output = activity.run(context); + if (output != null) { + return this.dataConverter.serialize(output); + } + + return null; + } + + private class TaskActivityContextImpl implements TaskActivityContext { + private final String name; + private final String rawInput; + private final String taskExecutionId; + private final int taskId; + + private final DataConverter dataConverter = TaskActivityExecutor.this.dataConverter; + + public TaskActivityContextImpl(String activityName, String rawInput, String taskExecutionId, int taskId) { + this.name = activityName; + this.rawInput = rawInput; + this.taskExecutionId = taskExecutionId; + this.taskId = taskId; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public T getInput(Class targetType) { + if (this.rawInput == null) { + return null; + } + + return this.dataConverter.deserialize(this.rawInput, targetType); + } + + @Override + public String getTaskExecutionId() { + return this.taskExecutionId; + } + + @Override + public int getTaskId() { + return this.taskId; + } + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityFactory.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityFactory.java new file mode 100644 index 000000000..e3ef45a95 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskActivityFactory.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +/** + * Factory interface for producing {@link TaskActivity} implementations. + */ +public interface TaskActivityFactory { + /** + * Gets the name of the activity this factory creates. + * + * @return the name of the activity + */ + String getName(); + + /** + * Creates a new instance of {@link TaskActivity}. + * + * @return the created activity instance + */ + TaskActivity create(); +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskCanceledException.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskCanceledException.java new file mode 100644 index 000000000..5b79882ed --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskCanceledException.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +//@TODO: This should inherit from Exception, not TaskFailedException + +/** + * Represents a task cancellation, either because of a timeout or because of an explicit cancellation operation. + */ +public final class TaskCanceledException extends TaskFailedException { + // Only intended to be created within this package + TaskCanceledException(String message, String taskName, int taskId) { + super(message, taskName, taskId, new FailureDetails(TaskCanceledException.class.getName(), message, "", true)); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskFailedException.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskFailedException.java new file mode 100644 index 000000000..377eecb42 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskFailedException.java @@ -0,0 +1,76 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +/** + * Exception that gets thrown when awaiting a {@link Task} for an activity or sub-orchestration that fails with an + * unhandled exception. + *

Detailed information associated with a particular task failure can be retrieved + * using the {@link #getErrorDetails()} method.

+ */ +public class TaskFailedException extends RuntimeException { + private final FailureDetails details; + private final String taskName; + private final int taskId; + + TaskFailedException(String taskName, int taskId, FailureDetails details) { + this(getExceptionMessage(taskName, taskId, details), taskName, taskId, details); + } + + TaskFailedException(String message, String taskName, int taskId, FailureDetails details) { + super(message); + this.taskName = taskName; + this.taskId = taskId; + this.details = details; + } + + /** + * Gets the ID of the failed task. + * + *

Each durable task (activities, timers, sub-orchestrations, etc.) scheduled by a task orchestrator has an + * auto-incrementing ID associated with it. This ID is used to distinguish tasks from one another, even if, for + * example, they are tasks that call the same activity. This ID can therefore be used to more easily correlate a + * specific task failure to a specific task.

+ * + * @return the ID of the failed task + */ + public int getTaskId() { + return this.taskId; + } + + /** + * Gets the name of the failed task. + * + * @return the name of the failed task + */ + public String getTaskName() { + return this.taskName; + } + + /** + * Gets the details of the task failure, including exception information. + * + * @return the details of the task failure + */ + public FailureDetails getErrorDetails() { + return this.details; + } + + private static String getExceptionMessage(String taskName, int taskId, FailureDetails details) { + return String.format("Task '%s' (#%d) failed with an unhandled exception: %s", + taskName, + taskId, + details.getErrorMessage()); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskOptions.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOptions.java new file mode 100644 index 000000000..e23ee54b7 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOptions.java @@ -0,0 +1,171 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +/** + * Options that can be used to control the behavior of orchestrator and activity task execution. + */ +public final class TaskOptions { + private final RetryPolicy retryPolicy; + private final RetryHandler retryHandler; + private final String appID; + + private TaskOptions(RetryPolicy retryPolicy, RetryHandler retryHandler, String appID) { + this.retryPolicy = retryPolicy; + this.retryHandler = retryHandler; + this.appID = appID; + } + + /** + * Creates a new builder for {@code TaskOptions}. + * + * @return a new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a new {@code TaskOptions} object with default values. + * + * @return a new TaskOptions instance with no configuration + */ + public static TaskOptions create() { + return new Builder().build(); + } + + /** + * Creates a new {@code TaskOptions} object from a {@link RetryPolicy}. + * + * @param retryPolicy the retry policy to use in the new {@code TaskOptions} object. + * @return a new TaskOptions instance with the specified retry policy + */ + public static TaskOptions withRetryPolicy(RetryPolicy retryPolicy) { + return new Builder().retryPolicy(retryPolicy).build(); + } + + /** + * Creates a new {@code TaskOptions} object from a {@link RetryHandler}. + * + * @param retryHandler the retry handler to use in the new {@code TaskOptions} object. + * @return a new TaskOptions instance with the specified retry handler + */ + public static TaskOptions withRetryHandler(RetryHandler retryHandler) { + return new Builder().retryHandler(retryHandler).build(); + } + + /** + * Creates a new {@code TaskOptions} object with the specified app ID. + * + * @param appID the app ID to use for cross-app workflow routing + * @return a new TaskOptions instance with the specified app ID + */ + public static TaskOptions withAppID(String appID) { + return new Builder().appID(appID).build(); + } + + boolean hasRetryPolicy() { + return this.retryPolicy != null; + } + + /** + * Gets the configured {@link RetryPolicy} value or {@code null} if none was configured. + * + * @return the configured retry policy + */ + public RetryPolicy getRetryPolicy() { + return this.retryPolicy; + } + + boolean hasRetryHandler() { + return this.retryHandler != null; + } + + /** + * Gets the configured {@link RetryHandler} value or {@code null} if none was configured. + * + * @return the configured retry handler. + */ + public RetryHandler getRetryHandler() { + return this.retryHandler; + } + + /** + * Gets the configured app ID value or {@code null} if none was configured. + * + * @return the configured app ID + */ + public String getAppID() { + return this.appID; + } + + boolean hasAppID() { + return this.appID != null && !this.appID.isEmpty(); + } + + /** + * Builder for creating {@code TaskOptions} instances. + */ + public static final class Builder { + private RetryPolicy retryPolicy; + private RetryHandler retryHandler; + private String appID; + + private Builder() { + // Private constructor -enforces using TaskOptions.builder() + } + + /** + * Sets the retry policy for the task options. + * + * @param retryPolicy the retry policy to use + * @return this builder instance for method chaining + */ + public Builder retryPolicy(RetryPolicy retryPolicy) { + this.retryPolicy = retryPolicy; + return this; + } + + /** + * Sets the retry handler for the task options. + * + * @param retryHandler the retry handler to use + * @return this builder instance for method chaining + */ + public Builder retryHandler(RetryHandler retryHandler) { + this.retryHandler = retryHandler; + return this; + } + + /** + * Sets the app ID for cross-app workflow routing. + * + * @param appID the app ID to use + * @return this builder instance for method chaining + */ + public Builder appID(String appID) { + this.appID = appID; + return this; + } + + /** + * Builds a new {@code TaskOptions} instance with the configured values. + * + * @return a new TaskOptions instance + */ + public TaskOptions build() { + return new TaskOptions(this.retryPolicy, this.retryHandler, this.appID); + } + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestration.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestration.java new file mode 100644 index 000000000..893531377 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestration.java @@ -0,0 +1,82 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +/** + * Common interface for task orchestrator implementations. + * + *

Task orchestrators describe how actions are executed and the order in which actions are executed. Orchestrators + * don't call into external services or do complex computation directly. Rather, they delegate these tasks to + * activities, which perform the actual work.

+ * + *

Orchestrators can be scheduled using the {@link DurableTaskClient#scheduleNewOrchestrationInstance} method + * overloads. Orchestrators can also invoke child orchestrators using the + * {@link TaskOrchestrationContext#callSubOrchestrator} method overloads.

+ * + *

Orchestrators may be replayed multiple times to rebuild their local state after being reloaded into memory. + * Orchestrator code must therefore be deterministic to ensure no unexpected side effects from execution + * replay. To account for this behavior, there are several coding constraints to be aware of:

+ *
    + *
  • + * An orchestrator must not generate random numbers or random UUIDs, get the current date, read environment + * variables, or do anything else that might result in a different value if the code is replayed in the future. + * Activities and built-in methods on the {@link TaskOrchestrationContext} parameter, like + * {@link TaskOrchestrationContext#getCurrentInstant()}, can be used to work around these restrictions. + *
  • + *
  • + * Orchestrator logic must be executed on the orchestrator thread. Creating new threads or scheduling callbacks + * onto background threads is forbidden and may result in failures or other unexpected behavior. + *
  • + *
  • + * Avoid infinite loops as they could cause the application to run out of memory. Instead, ensure that loops are + * bounded or use {@link TaskOrchestrationContext#continueAsNew} to restart an orchestrator with a new input. + *
  • + *
  • + * Avoid logging directly in the orchestrator code because log messages will be duplicated on each replay. + * Instead, check the value of the {@link TaskOrchestrationContext#getIsReplaying} method and write log messages + * only when it is {@code false}. + *
  • + *
+ * + *

Orchestrator code is tightly coupled with its execution history so special care must be taken when making changes + * to orchestrator code. For example, adding or removing activity tasks to an orchestrator's code may cause a + * mismatch between code and history for in-flight orchestrations. To avoid potential issues related to orchestrator + * versioning, consider applying the following strategies:

+ *
    + *
  • + * Deploy multiple versions of applications side-by-side allowing new code to run independently of old code. + *
  • + *
  • + * Rather than changing existing orchestrators, create new orchestrators that implement the modified behavior. + *
  • + *
  • + * Ensure all in-flight orchestrations are complete before applying code changes to existing orchestrator code. + *
  • + *
  • + * If possible, only make changes to orchestrator code that won't impact its history or execution path. For + * example, renaming variables or adding log statements have no impact on an orchestrator's execution path and + * are safe to apply to existing orchestrations. + *
  • + *
+ */ +@FunctionalInterface +public interface TaskOrchestration { + /** + * Executes the orchestrator logic. + * + * @param ctx provides access to methods for scheduling durable tasks and getting information about the current + * orchestration instance. + */ + void run(TaskOrchestrationContext ctx); +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationContext.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationContext.java new file mode 100644 index 000000000..df0c95ec8 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationContext.java @@ -0,0 +1,598 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import javax.annotation.Nullable; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +/** + * Used by orchestrators to perform actions such as scheduling tasks, durable timers, waiting for external events, + * and for getting basic information about the current orchestration. + */ +public interface TaskOrchestrationContext { + /** + * Gets the name of the current task orchestration. + * + * @return the name of the current task orchestration + */ + String getName(); + + /** + * Gets the deserialized input of the current task orchestration. + * + * @param targetType the {@link Class} object associated with {@code V} + * @param the expected type of the orchestrator input + * @return the deserialized input as an object of type {@code V} or {@code null} if no input was provided. + */ + V getInput(Class targetType); + + /** + * Gets the unique ID of the current orchestration instance. + * + * @return the unique ID of the current orchestration instance + */ + String getInstanceId(); + + /** + * Gets the app ID of the current orchestration instance, if available. + * This is used for cross-app workflow routing. + * + * @return the app ID of the current orchestration instance, or null if not available + */ + String getAppId(); + + /** + * Gets the current orchestration time in UTC. + * + * @return the current orchestration time in UTC + */ + Instant getCurrentInstant(); + + /** + * Gets a value indicating whether the orchestrator is currently replaying a previous execution. + * + *

Orchestrator functions are "replayed" after being unloaded from memory to reconstruct local variable state. + * During a replay, previously executed tasks will be completed automatically with previously seen values + * that are stored in the orchestration history. One the orchestrator reaches the point in the orchestrator + * where it's no longer replaying existing history, this method will return {@code false}.

+ * + *

You can use this method if you have logic that needs to run only when not replaying. For example, + * certain types of application logging may become too noisy when duplicated as part of replay. The + * application code could check to see whether the function is being replayed and then issue the log statements + * when this value is {@code false}.

+ * + * @return {@code true} if the orchestrator is replaying, otherwise {@code false} + */ + boolean getIsReplaying(); + + /** + * Returns a new {@code Task} that is completed when all tasks in {@code tasks} completes. + * See {@link #allOf(Task[])} for more detailed information. + * + * @param tasks the list of {@code Task} objects + * @param the return type of the {@code Task} objects + * @return a new {@code Task} that is completed when any of the given {@code Task}s complete + * @see #allOf(Task[]) + */ + Task> allOf(List> tasks); + + // TODO: Update the description of allOf to be more specific about the exception behavior. + + // https://github.io.dapr.durabletask-java/issues/54 + + /** + * Returns a new {@code Task} that is completed when all the given {@code Task}s complete. If any of the given + * {@code Task}s complete with an exception, the returned {@code Task} will also complete with + * an {@link CompositeTaskFailedException} containing details of the first encountered failure. + * The value of the returned {@code Task} is an ordered list of + * the return values of the given tasks. If no tasks are provided, returns a {@code Task} completed with value + * {@code null}. + * + *

This method is useful for awaiting the completion of a set of independent tasks before continuing to the next + * step in the orchestration, as in the following example:

+ *
{@code
+   * Task t1 = ctx.callActivity("MyActivity", String.class);
+   * Task t2 = ctx.callActivity("MyActivity", String.class);
+   * Task t3 = ctx.callActivity("MyActivity", String.class);
+   *
+   * List orderedResults = ctx.allOf(t1, t2, t3).await();
+   * }
+ * + *

Exceptions in any of the given tasks results in an unchecked {@link CompositeTaskFailedException}. + * This exception can be inspected to obtain failure details of individual {@link Task}s.

+ *
{@code
+   * try {
+   *     List orderedResults = ctx.allOf(t1, t2, t3).await();
+   * } catch (CompositeTaskFailedException e) {
+   *     List exceptions = e.getExceptions()
+   * }
+   * }
+ * + * @param tasks the {@code Task}s + * @param the return type of the {@code Task} objects + * @return the values of the completed {@code Task} objects in the same order as the source list + */ + default Task> allOf(Task... tasks) { + return this.allOf(Arrays.asList(tasks)); + } + + /** + * Returns a new {@code Task} that is completed when any of the tasks in {@code tasks} completes. + * See {@link #anyOf(Task[])} for more detailed information. + * + * @param tasks the list of {@code Task} objects + * @return a new {@code Task} that is completed when any of the given {@code Task}s complete + * @see #anyOf(Task[]) + */ + Task> anyOf(List> tasks); + + /** + * Returns a new {@code Task} that is completed when any of the given {@code Task}s complete. The value of the + * new {@code Task} is a reference to the completed {@code Task} object. If no tasks are provided, returns a + * {@code Task} that never completes. + * + *

This method is useful for waiting on multiple concurrent tasks and performing a task-specific operation when the + * first task completes, as in the following example:

+ *
{@code
+   * Task event1 = ctx.waitForExternalEvent("Event1");
+   * Task event2 = ctx.waitForExternalEvent("Event2");
+   * Task event3 = ctx.waitForExternalEvent("Event3");
+   *
+   * Task winner = ctx.anyOf(event1, event2, event3).await();
+   * if (winner == event1) {
+   *     // ...
+   * } else if (winner == event2) {
+   *     // ...
+   * } else if (winner == event3) {
+   *     // ...
+   * }
+   * }
+ * + *

The {@code anyOf} method can also be used for implementing long-running timeouts, as in the following example: + *

+ *
{@code
+   * Task activityTask = ctx.callActivity("SlowActivity");
+   * Task timeoutTask = ctx.createTimer(Duration.ofMinutes(30));
+   *
+   * Task winner = ctx.anyOf(activityTask, timeoutTask).await();
+   * if (winner == activityTask) {
+   *     // completion case
+   * } else {
+   *     // timeout case
+   * }
+   * }
+ * + * @param tasks the list of {@code Task} objects + * @return a new {@code Task} that is completed when any of the given {@code Task}s complete + */ + default Task> anyOf(Task... tasks) { + return this.anyOf(Arrays.asList(tasks)); + } + + /** + * Creates a durable timer that expires after the specified delay. + * + *

Specifying a long delay (for example, a delay of a few days or more) may result in the creation of multiple, + * internally-managed durable timers. The orchestration code doesn't need to be aware of this behavior. However, + * it may be visible in framework logs and the stored history state.

+ * + * @param delay the amount of time before the timer should expire + * @return a new {@code Task} that completes after the specified delay + */ + Task createTimer(Duration delay); + + /** + * Creates a durable timer that expires after the specified timestamp with specific zone. + * + *

Specifying a long delay (for example, a delay of a few days or more) may result in the creation of multiple, + * internally-managed durable timers. The orchestration code doesn't need to be aware of this behavior. However, + * it may be visible in framework logs and the stored history state.

+ * + * @param zonedDateTime timestamp with specific zone when the timer should expire + * @return a new {@code Task} that completes after the specified delay + */ + Task createTimer(ZonedDateTime zonedDateTime); + + /** + * Transitions the orchestration into the {@link OrchestrationRuntimeStatus#COMPLETED} state with the given output. + * + * @param output the serializable output of the completed orchestration + */ + void complete(Object output); + + /** + * Asynchronously invokes an activity by name and with the specified input value and returns a new {@link Task} + * that completes when the activity completes. If the activity completes successfully, the returned {@code Task}'s + * value will be the activity's output. If the activity fails, the returned {@code Task} will complete exceptionally + * with a {@link TaskFailedException}. + * + *

Activities are the basic unit of work in a durable task orchestration. Unlike orchestrators, which are not + * allowed to do any I/O or call non-deterministic APIs, activities have no implementation restrictions.

+ * + *

An activity may execute in the local machine or a remote machine. The exact behavior depends on the underlying + * storage provider, which is responsible for distributing tasks across machines. In general, you should never make + * any assumptions about where an activity will run. You should also assume at-least-once execution guarantees for + * activities, meaning that an activity may be executed twice if, for example, there is a process failure before + * the activities result is saved into storage.

+ * + *

Both the inputs and outputs of activities are serialized and stored in durable storage. It's highly recommended + * to not include any sensitive data in activity inputs or outputs. It's also recommended to not use large payloads + * for activity inputs and outputs, which can result in expensive serialization and network utilization. For data + * that cannot be cheaply or safely persisted to storage, it's recommended to instead pass references + * (for example, a URL to a storage blog) to the data and have activities fetch the data directly as part of their + * implementation.

+ * + * @param name the name of the activity to call + * @param input the serializable input to pass to the activity + * @param options additional options that control the execution and processing of the activity + * @param returnType the expected class type of the activity output + * @param the expected type of the activity output + * @return a new {@link Task} that completes when the activity completes or fails + */ + Task callActivity(String name, Object input, TaskOptions options, Class returnType); + + /** + * Asynchronously invokes an activity by name and returns a new {@link Task} that completes when the activity + * completes. See {@link #callActivity(String, Object, TaskOptions, Class)} for a complete description. + * + * @param name the name of the activity to call + * @return a new {@link Task} that completes when the activity completes or fails + * @see #callActivity(String, Object, TaskOptions, Class) + */ + default Task callActivity(String name) { + return this.callActivity(name, Void.class); + } + + /** + * Asynchronously invokes an activity by name and with the specified input value and returns a new {@link Task} + * that completes when the activity completes. See {@link #callActivity(String, Object, TaskOptions, Class)} for a + * complete description. + * + * @param name the name of the activity to call + * @param input the serializable input to pass to the activity + * @return a new {@link Task} that completes when the activity completes or fails + */ + default Task callActivity(String name, Object input) { + return this.callActivity(name, input, null, Void.class); + } + + /** + * Asynchronously invokes an activity by name and returns a new {@link Task} that completes when the activity + * completes. If the activity completes successfully, the returned {@code Task}'s value will be the activity's + * output. See {@link #callActivity(String, Object, TaskOptions, Class)} for a complete description. + * + * @param name the name of the activity to call + * @param returnType the expected class type of the activity output + * @param the expected type of the activity output + * @return a new {@link Task} that completes when the activity completes or fails + */ + default Task callActivity(String name, Class returnType) { + return this.callActivity(name, null, null, returnType); + } + + /** + * Asynchronously invokes an activity by name and with the specified input value and returns a new {@link Task} + * that completes when the activity completes.If the activity completes successfully, the returned {@code Task}'s + * value will be the activity's output. See {@link #callActivity(String, Object, TaskOptions, Class)} for a + * complete description. + * + * @param name the name of the activity to call + * @param input the serializable input to pass to the activity + * @param returnType the expected class type of the activity output + * @param the expected type of the activity output + * @return a new {@link Task} that completes when the activity completes or fails + */ + default Task callActivity(String name, Object input, Class returnType) { + return this.callActivity(name, input, null, returnType); + } + + /** + * Asynchronously invokes an activity by name and with the specified input value and returns a new {@link Task} + * that completes when the activity completes. See {@link #callActivity(String, Object, TaskOptions, Class)} for a + * complete description. + * + * @param name the name of the activity to call + * @param input the serializable input to pass to the activity + * @param options additional options that control the execution and processing of the activity + * @return a new {@link Task} that completes when the activity completes or fails + */ + default Task callActivity(String name, Object input, TaskOptions options) { + return this.callActivity(name, input, options, Void.class); + } + + /** + * Restarts the orchestration with a new input and clears its history. See {@link #continueAsNew(Object, boolean)} + * for a full description. + * + * @param input the serializable input data to re-initialize the instance with + */ + default void continueAsNew(Object input) { + this.continueAsNew(input, true); + } + + /** + * Restarts the orchestration with a new input and clears its history. + * + *

This method is primarily designed for eternal orchestrations, which are orchestrations that + * may not ever complete. It works by restarting the orchestration, providing it with a new input, + * and truncating the existing orchestration history. It allows an orchestration to continue + * running indefinitely without having its history grow unbounded. The benefits of periodically + * truncating history include decreased memory usage, decreased storage volumes, and shorter orchestrator + * replays when rebuilding state.

+ * + *

The results of any incomplete tasks will be discarded when an orchestrator calls {@code continueAsNew}. + * For example, if a timer is scheduled and then {@code continueAsNew} is called before the timer fires, the timer + * event will be discarded. The only exception to this is external events. By default, if an external event is + * received by an orchestration but not yet processed, the event is saved in the orchestration state unit it is + * received by a call to {@link #waitForExternalEvent}. These events will remain in memory + * even after an orchestrator restarts using {@code continueAsNew}. This behavior can be disabled by specifying + * {@code false} for the {@code preserveUnprocessedEvents} parameter value.

+ * + *

Orchestrator implementations should complete immediately after calling the{@code continueAsNew} method.

+ * + * @param input the serializable input data to re-initialize the instance with + * @param preserveUnprocessedEvents {@code true} to push unprocessed external events into the new orchestration + * history, otherwise {@code false} + */ + void continueAsNew(Object input, boolean preserveUnprocessedEvents); + + /** + * Create a new Uuid that is safe for replay within an orchestration or operation. + * + *

The default implementation of this method creates a name-based Uuid + * using the algorithm from RFC 4122 §4.3. The name input used to generate + * this value is a combination of the orchestration instance ID and an + * internally managed sequence number. + *

+ * + * @return a deterministic Uuid + */ + default UUID newUuid() { + throw new RuntimeException("No implementation found."); + } + + /** + * Sends an external event to another orchestration instance. + * + * @param instanceID the unique ID of the receiving orchestration instance. + * @param eventName the name of the event to send + */ + default void sendEvent(String instanceID, String eventName) { + this.sendEvent(instanceID, eventName, null); + } + + /** + * Sends an external event to another orchestration instance. + * + * @param instanceId the unique ID of the receiving orchestration instance. + * @param eventName the name of the event to send + * @param eventData the payload of the event to send + */ + void sendEvent(String instanceId, String eventName, Object eventData); + + /** + * Asynchronously invokes another orchestrator as a sub-orchestration and returns a {@link Task} that completes + * when the sub-orchestration completes. + * + *

See {@link #callSubOrchestrator(String, Object, String, TaskOptions, Class)} for a full description.

+ * + * @param name the name of the orchestrator to invoke + * @return a new {@link Task} that completes when the sub-orchestration completes or fails + * @see #callSubOrchestrator(String, Object, String, TaskOptions, Class) + */ + default Task callSubOrchestrator(String name) { + return this.callSubOrchestrator(name, null); + } + + /** + * Asynchronously invokes another orchestrator as a sub-orchestration and returns a {@link Task} that completes + * when the sub-orchestration completes. + * + *

See {@link #callSubOrchestrator(String, Object, String, TaskOptions, Class)} for a full description.

+ * + * @param name the name of the orchestrator to invoke + * @param input the serializable input to send to the sub-orchestration + * @return a new {@link Task} that completes when the sub-orchestration completes or fails + */ + default Task callSubOrchestrator(String name, Object input) { + return this.callSubOrchestrator(name, input, null); + } + + /** + * Asynchronously invokes another orchestrator as a sub-orchestration and returns a {@link Task} that completes + * when the sub-orchestration completes. + * + *

See {@link #callSubOrchestrator(String, Object, String, TaskOptions, Class)} for a full description.

+ * + * @param name the name of the orchestrator to invoke + * @param input the serializable input to send to the sub-orchestration + * @param returnType the expected class type of the sub-orchestration output + * @param the expected type of the sub-orchestration output + * @return a new {@link Task} that completes when the sub-orchestration completes or fails + */ + default Task callSubOrchestrator(String name, Object input, Class returnType) { + return this.callSubOrchestrator(name, input, null, returnType); + } + + /** + * Asynchronously invokes another orchestrator as a sub-orchestration and returns a {@link Task} that completes + * when the sub-orchestration completes. + * + *

See {@link #callSubOrchestrator(String, Object, String, TaskOptions, Class)} for a full description.

+ * + * @param name the name of the orchestrator to invoke + * @param input the serializable input to send to the sub-orchestration + * @param instanceID the unique ID of the sub-orchestration + * @param returnType the expected class type of the sub-orchestration output + * @param the expected type of the sub-orchestration output + * @return a new {@link Task} that completes when the sub-orchestration completes or fails + */ + default Task callSubOrchestrator(String name, Object input, String instanceID, Class returnType) { + return this.callSubOrchestrator(name, input, instanceID, null, returnType); + } + + /** + * Asynchronously invokes another orchestrator as a sub-orchestration and returns a {@link Task} that completes + * when the sub-orchestration completes. + * + *

See {@link #callSubOrchestrator(String, Object, String, TaskOptions, Class)} for a full description.

+ * + * @param name the name of the orchestrator to invoke + * @param input the serializable input to send to the sub-orchestration + * @param instanceID the unique ID of the sub-orchestration + * @param options additional options that control the execution and processing of the activity + * @return a new {@link Task} that completes when the sub-orchestration completes or fails + */ + default Task callSubOrchestrator(String name, Object input, String instanceID, TaskOptions options) { + return this.callSubOrchestrator(name, input, instanceID, options, Void.class); + } + + /** + * Asynchronously invokes another orchestrator as a sub-orchestration and returns a {@link Task} that completes + * when the sub-orchestration completes. If the sub-orchestration completes successfully, the returned + * {@code Task}'s value will be the activity's output. If the sub-orchestration fails, the returned {@code Task} + * will complete exceptionally with a {@link TaskFailedException}. + * + *

A sub-orchestration has its own instance ID, history, and status that is independent of the parent orchestrator + * that started it. There are many advantages to breaking down large orchestrations into sub-orchestrations:

+ *
    + *
  • + * Splitting large orchestrations into a series of smaller sub-orchestrations can make code more maintainable. + *
  • + *
  • + * Distributing orchestration logic across multiple compute nodes concurrently is useful if + * orchestration logic otherwise needs to coordinate a lot of tasks. + *
  • + *
  • + * Memory usage and CPU overhead can be reduced by keeping the history of parent orchestrations smaller. + *
  • + *
+ * + *

The disadvantage is that there is overhead associated with starting a sub-orchestration and processing its + * output. This is typically only an issue for very small orchestrations.

+ * + *

Because sub-orchestrations are independent of their parents, terminating a parent orchestration does not affect + * any sub-orchestrations. Sub-orchestrations must be terminated independently using their unique instance ID, + * which is specified using the {@code instanceID} parameter.

+ * + * @param name the name of the orchestrator to invoke + * @param input the serializable input to send to the sub-orchestration + * @param instanceID the unique ID of the sub-orchestration + * @param options additional options that control the execution and processing of the activity + * @param returnType the expected class type of the sub-orchestration output + * @param the expected type of the sub-orchestration output + * @return a new {@link Task} that completes when the sub-orchestration completes or fails + */ + Task callSubOrchestrator( + String name, + @Nullable Object input, + @Nullable String instanceID, + @Nullable TaskOptions options, + Class returnType); + + /** + * Waits for an event to be raised named {@code name} and returns a {@link Task} that completes when the event is + * received or is canceled when {@code timeout} expires. + * + *

External clients can raise events to a waiting orchestration instance using the + * {@link DurableTaskClient#raiseEvent} method.

+ * + *

If the current orchestration is not yet waiting for an event named {@code name}, then the event will be saved in + * the orchestration instance state and dispatched immediately when this method is called. This event saving occurs + * even if the current orchestrator cancels the wait operation before the event is received.

+ * + *

Orchestrators can wait for the same event name multiple times, so waiting for multiple events with the same name + * is allowed. Each external event received by an orchestrator will complete just one task returned by this method. + *

+ * + * @param name the case-insensitive name of the event to wait for + * @param timeout the amount of time to wait before canceling the returned {@code Task} + * @param dataType the expected class type of the event data payload + * @param the expected type of the event data payload + * @return a new {@link Task} that completes when the external event is received or when {@code timeout} expires + * @throws TaskCanceledException if the specified {@code timeout} value expires before the event is received + */ + Task waitForExternalEvent(String name, Duration timeout, Class dataType) throws TaskCanceledException; + + /** + * Waits for an event to be raised named {@code name} and returns a {@link Task} that completes when the event is + * received or is canceled when {@code timeout} expires. + * + *

See {@link #waitForExternalEvent(String, Duration, Class)} for a full description.

+ * + * @param name the case-insensitive name of the event to wait for + * @param timeout the amount of time to wait before canceling the returned {@code Task} + * @return a new {@link Task} that completes when the external event is received or when {@code timeout} expires + * @throws TaskCanceledException if the specified {@code timeout} value expires before the event is received + */ + default Task waitForExternalEvent(String name, Duration timeout) throws TaskCanceledException { + return this.waitForExternalEvent(name, timeout, Void.class); + } + + /** + * Waits for an event to be raised named {@code name} and returns a {@link Task} that completes when the event is + * received. + * + *

See {@link #waitForExternalEvent(String, Duration, Class)} for a full description.

+ * + * @param name the case-insensitive name of the event to wait for + * @return a new {@link Task} that completes when the external event is received + */ + default Task waitForExternalEvent(String name) { + return this.waitForExternalEvent(name, Void.class); + } + + /** + * Waits for an event to be raised named {@code name} and returns a {@link Task} that completes when the event is + * received. + * + *

See {@link #waitForExternalEvent(String, Duration, Class)} for a full description.

+ * + * @param name the case-insensitive name of the event to wait for + * @param dataType the expected class type of the event data payload + * @param the expected type of the event data payload + * @return a new {@link Task} that completes when the external event is received + */ + default Task waitForExternalEvent(String name, Class dataType) { + try { + return this.waitForExternalEvent(name, null, dataType); + } catch (TaskCanceledException e) { + // This should never happen because of the max duration + throw new RuntimeException("An unexpected exception was throw while waiting for an external event.", e); + } + } + + /** + * Assigns a custom status value to the current orchestration. + * + *

The {@code customStatus} value is serialized and stored in orchestration state and will be made available to the + * orchestration status query APIs, such as {@link DurableTaskClient#getInstanceMetadata}. The serialized value + * must not exceed 16 KB of UTF-16 encoded text.

+ * + *

Use {@link #clearCustomStatus()} to remove the custom status value from the orchestration state.

+ * + * @param customStatus A serializable value to assign as the custom status value. + */ + void setCustomStatus(Object customStatus); + + /** + * Clears the orchestration's custom status. + */ + void clearCustomStatus(); +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationExecutor.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationExecutor.java new file mode 100644 index 000000000..7a3436b03 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationExecutor.java @@ -0,0 +1,1515 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import com.google.protobuf.StringValue; +import com.google.protobuf.Timestamp; +import io.dapr.durabletask.implementation.protobuf.OrchestratorService; +import io.dapr.durabletask.implementation.protobuf.OrchestratorService.ScheduleTaskAction.Builder; +import io.dapr.durabletask.interruption.ContinueAsNewInterruption; +import io.dapr.durabletask.interruption.OrchestratorBlockedException; +import io.dapr.durabletask.util.UuidGenerator; + +import javax.annotation.Nullable; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.IntFunction; +import java.util.logging.Logger; + +final class TaskOrchestrationExecutor { + + private static final String EMPTY_STRING = ""; + private final HashMap orchestrationFactories; + private final DataConverter dataConverter; + private final Logger logger; + private final Duration maximumTimerInterval; + private final String appId; + + public TaskOrchestrationExecutor( + HashMap orchestrationFactories, + DataConverter dataConverter, + Duration maximumTimerInterval, + Logger logger, + String appId) { + this.orchestrationFactories = orchestrationFactories; + this.dataConverter = dataConverter; + this.maximumTimerInterval = maximumTimerInterval; + this.logger = logger; + this.appId = appId; // extracted from router + } + + public TaskOrchestratorResult execute(List pastEvents, + List newEvents) { + ContextImplTask context = new ContextImplTask(pastEvents, newEvents); + + boolean completed = false; + try { + // Play through the history events until either we've played through everything + // or we receive a yield signal + while (context.processNextEvent()) { + /* no method body */ + } + completed = true; + logger.finest("The orchestrator execution completed normally"); + } catch (OrchestratorBlockedException orchestratorBlockedException) { + logger.fine("The orchestrator has yielded and will await for new events."); + } catch (ContinueAsNewInterruption continueAsNewInterruption) { + logger.fine("The orchestrator has continued as new."); + context.complete(null); + } catch (Exception e) { + // The orchestrator threw an unhandled exception - fail it + // TODO: What's the right way to log this? + logger.warning("The orchestrator failed with an unhandled exception: " + e.toString()); + context.fail(new FailureDetails(e)); + } + + if ((context.continuedAsNew && !context.isComplete) || (completed && context.pendingActions.isEmpty() + && !context.waitingForEvents())) { + // There are no further actions for the orchestrator to take so auto-complete the orchestration. + context.complete(null); + } + + return new TaskOrchestratorResult(context.pendingActions.values(), context.getCustomStatus()); + } + + private class ContextImplTask implements TaskOrchestrationContext { + + private String orchestratorName; + private String rawInput; + private String instanceId; + private Instant currentInstant; + private boolean isComplete; + private boolean isSuspended; + private boolean isReplaying = true; + private int newUuidCounter; + private String appId; + + // LinkedHashMap to maintain insertion order when returning the list of pending actions + private final Map pendingActions = new LinkedHashMap<>(); + private final Map> openTasks = new HashMap<>(); + private final Map>> outstandingEvents = new LinkedHashMap<>(); + private final List unprocessedEvents = new LinkedList<>(); + private final Queue eventsWhileSuspended = new ArrayDeque<>(); + private final DataConverter dataConverter = TaskOrchestrationExecutor.this.dataConverter; + private final Duration maximumTimerInterval = TaskOrchestrationExecutor.this.maximumTimerInterval; + private final Logger logger = TaskOrchestrationExecutor.this.logger; + private final OrchestrationHistoryIterator historyEventPlayer; + private int sequenceNumber; + private boolean continuedAsNew; + private Object continuedAsNewInput; + private boolean preserveUnprocessedEvents; + private Object customStatus; + + public ContextImplTask(List pastEvents, + List newEvents) { + this.historyEventPlayer = new OrchestrationHistoryIterator(pastEvents, newEvents); + } + + @Override + public String getName() { + // TODO: Throw if name is null + return this.orchestratorName; + } + + private void setName(String name) { + // TODO: Throw if name is not null + this.orchestratorName = name; + } + + private void setInput(String rawInput) { + this.rawInput = rawInput; + } + + @Override + public T getInput(Class targetType) { + if (this.rawInput == null || this.rawInput.length() == 0) { + return null; + } + + return this.dataConverter.deserialize(this.rawInput, targetType); + } + + @Override + public String getInstanceId() { + // TODO: Throw if instance ID is null + return this.instanceId; + } + + private void setInstanceId(String instanceId) { + // TODO: Throw if instance ID is not null + this.instanceId = instanceId; + } + + @Override + public String getAppId() { + return this.appId; + } + + private void setAppId(String appId) { + this.appId = appId; + } + + @Override + public Instant getCurrentInstant() { + // TODO: Throw if instant is null + return this.currentInstant; + } + + private void setCurrentInstant(Instant instant) { + // This will be set multiple times as the orchestration progresses + this.currentInstant = instant; + } + + private String getCustomStatus() { + return this.customStatus != null ? this.dataConverter.serialize(this.customStatus) : EMPTY_STRING; + } + + @Override + public void setCustomStatus(Object customStatus) { + this.customStatus = customStatus; + } + + @Override + public void clearCustomStatus() { + this.setCustomStatus(null); + } + + @Override + public boolean getIsReplaying() { + return this.isReplaying; + } + + private void setDoneReplaying() { + this.isReplaying = false; + } + + public Task completedTask(V value) { + CompletableTask task = new CompletableTask<>(); + task.complete(value); + return task; + } + + @Override + public Task> allOf(List> tasks) { + Helpers.throwIfArgumentNull(tasks, "tasks"); + + CompletableFuture[] futures = tasks.stream() + .map(t -> t.future) + .toArray((IntFunction[]>) CompletableFuture[]::new); + + Function> resultPath = x -> { + List results = new ArrayList<>(futures.length); + + // All futures are expected to be completed at this point + for (CompletableFuture cf : futures) { + try { + results.add(cf.get()); + } catch (Exception ex) { + results.add(null); + } + } + return results; + }; + + Function> exceptionPath = throwable -> { + ArrayList exceptions = new ArrayList<>(futures.length); + for (CompletableFuture cf : futures) { + try { + cf.get(); + } catch (ExecutionException ex) { + exceptions.add((Exception) ex.getCause()); + } catch (Exception ex) { + exceptions.add(ex); + } + } + throw new CompositeTaskFailedException( + String.format( + "%d out of %d tasks failed with an exception. See the exceptions list for details.", + exceptions.size(), + futures.length), + exceptions); + }; + CompletableFuture> future = CompletableFuture.allOf(futures) + .thenApply(resultPath) + .exceptionally(exceptionPath); + + return new CompoundTask<>(tasks, future); + } + + @Override + public Task> anyOf(List> tasks) { + Helpers.throwIfArgumentNull(tasks, "tasks"); + + CompletableFuture[] futures = tasks.stream() + .map(t -> t.future) + .toArray((IntFunction[]>) CompletableFuture[]::new); + + CompletableFuture> future = CompletableFuture.anyOf(futures).thenApply(x -> { + // Return the first completed task in the list. Unlike the implementation in other languages, + // this might not necessarily be the first task that completed, so calling code shouldn't make + // assumptions about this. Note that changing this behavior later could be breaking. + for (Task task : tasks) { + if (task.isDone()) { + return task; + } + } + + // Should never get here + return completedTask(null); + }); + + return new CompoundTask(tasks, future); + } + + @Override + public Task callActivity( + String name, + @Nullable Object input, + @Nullable TaskOptions options, + Class returnType) { + Helpers.throwIfOrchestratorComplete(this.isComplete); + Helpers.throwIfArgumentNull(name, "name"); + Helpers.throwIfArgumentNull(returnType, "returnType"); + + if (input instanceof TaskOptions) { + throw new IllegalArgumentException("TaskOptions cannot be used as an input. " + + "Did you call the wrong method overload?"); + } + + String serializedInput = this.dataConverter.serialize(input); + Builder scheduleTaskBuilder = OrchestratorService.ScheduleTaskAction.newBuilder().setName(name) + .setTaskExecutionId(newUuid().toString()); + if (serializedInput != null) { + scheduleTaskBuilder.setInput(StringValue.of(serializedInput)); + } + + // Add router information for cross-app routing + // Router always has a source app ID from EXECUTIONSTARTED event + OrchestratorService.TaskRouter.Builder routerBuilder = OrchestratorService.TaskRouter.newBuilder() + .setSourceAppID(this.appId); + + // Add target app ID if specified in options + if (options != null && options.hasAppID()) { + String targetAppId = options.getAppID(); + OrchestratorService.TaskRouter router = OrchestratorService.TaskRouter.newBuilder() + .setSourceAppID(this.appId) + .setTargetAppID(targetAppId) + .build(); + scheduleTaskBuilder.setRouter(router); + this.logger.fine(() -> String.format( + "cross app routing detected: source=%s, target=%s", + this.appId, targetAppId)); + } + TaskFactory taskFactory = () -> { + int id = this.sequenceNumber++; + OrchestratorService.ScheduleTaskAction scheduleTaskAction = scheduleTaskBuilder.build(); + OrchestratorService.OrchestratorAction.Builder actionBuilder = OrchestratorService.OrchestratorAction + .newBuilder() + .setId(id) + .setScheduleTask(scheduleTaskBuilder); + if (options != null && options.hasAppID()) { + String targetAppId = options.getAppID(); + OrchestratorService.TaskRouter actionRouter = OrchestratorService.TaskRouter.newBuilder() + .setSourceAppID(this.appId) + .setTargetAppID(targetAppId) + .build(); + actionBuilder.setRouter(actionRouter); + } + this.pendingActions.put(id, actionBuilder.build()); + + if (!this.isReplaying) { + this.logger.fine(() -> String.format( + "%s: calling activity '%s' (#%d) with serialized input: %s", + this.instanceId, + name, + id, + serializedInput != null ? serializedInput : "(null)")); + } + + CompletableTask task = new CompletableTask<>(); + TaskRecord record = new TaskRecord<>(task, name, returnType); + this.openTasks.put(id, record); + return task; + }; + + return this.createAppropriateTask(taskFactory, options); + } + + @Override + public void continueAsNew(Object input, boolean preserveUnprocessedEvents) { + Helpers.throwIfOrchestratorComplete(this.isComplete); + + this.continuedAsNew = true; + this.continuedAsNewInput = input; + this.preserveUnprocessedEvents = preserveUnprocessedEvents; + + // The ContinueAsNewInterruption exception allows the orchestration to complete immediately and return back + // to the sidecar. + // We can send the current set of actions back to the worker and wait for new events to come in. + // This is *not* an exception - it's a normal part of orchestrator control flow. + throw new ContinueAsNewInterruption( + "The orchestrator invoked continueAsNew. This Throwable should never be caught by user code."); + } + + @Override + public UUID newUuid() { + final int version = 5; + final String hashV5 = "SHA-1"; + final String dnsNameSpace = "9e952958-5e33-4daf-827f-2fa12937b875"; + final String name = new StringBuilder(this.instanceId) + .append("-") + .append(this.currentInstant) + .append("-") + .append(this.newUuidCounter).toString(); + this.newUuidCounter++; + return UuidGenerator.generate(version, hashV5, UUID.fromString(dnsNameSpace), name); + } + + @Override + public void sendEvent(String instanceId, String eventName, Object eventData) { + Helpers.throwIfOrchestratorComplete(this.isComplete); + Helpers.throwIfArgumentNullOrWhiteSpace(instanceId, "instanceId"); + + int id = this.sequenceNumber++; + String serializedEventData = this.dataConverter.serialize(eventData); + OrchestratorService.OrchestrationInstance.Builder orchestrationInstanceBuilder = + OrchestratorService.OrchestrationInstance.newBuilder() + .setInstanceId(instanceId); + OrchestratorService.SendEventAction.Builder builder = OrchestratorService + .SendEventAction.newBuilder().setInstance(orchestrationInstanceBuilder) + .setName(eventName); + if (serializedEventData != null) { + builder.setData(StringValue.of(serializedEventData)); + } + OrchestratorService.OrchestratorAction.Builder actionBuilder = OrchestratorService.OrchestratorAction.newBuilder() + .setId(id) + .setSendEvent(builder); + + this.pendingActions.put(id, actionBuilder.build()); + + if (!this.isReplaying) { + this.logger.fine(() -> String.format( + "%s: sending event '%s' (#%d) with serialized event data: %s", + this.instanceId, + eventName, + id, + serializedEventData != null ? serializedEventData : "(null)")); + } + } + + @Override + public Task callSubOrchestrator( + String name, + @Nullable Object input, + @Nullable String instanceId, + @Nullable TaskOptions options, + Class returnType) { + Helpers.throwIfOrchestratorComplete(this.isComplete); + Helpers.throwIfArgumentNull(name, "name"); + Helpers.throwIfArgumentNull(returnType, "returnType"); + + if (input instanceof TaskOptions) { + throw new IllegalArgumentException("TaskOptions cannot be used as an input. " + + "Did you call the wrong method overload?"); + } + + String serializedInput = this.dataConverter.serialize(input); + OrchestratorService.CreateSubOrchestrationAction.Builder createSubOrchestrationActionBuilder = + OrchestratorService.CreateSubOrchestrationAction + .newBuilder().setName(name); + if (serializedInput != null) { + createSubOrchestrationActionBuilder.setInput(StringValue.of(serializedInput)); + } + + if (instanceId == null) { + instanceId = this.newUuid().toString(); + } + createSubOrchestrationActionBuilder.setInstanceId(instanceId); + + // TODO: @cicoyle - add suborchestration cross app logic here when its supported + TaskFactory taskFactory = () -> { + int id = this.sequenceNumber++; + this.pendingActions.put(id, OrchestratorService.OrchestratorAction.newBuilder() + .setId(id) + .setCreateSubOrchestration(createSubOrchestrationActionBuilder) + .build()); + + if (!this.isReplaying) { + this.logger.fine(() -> String.format( + "%s: calling sub-orchestration '%s' (#%d) with serialized input: %s", + this.instanceId, + name, + id, + serializedInput != null ? serializedInput : "(null)")); + } + + CompletableTask task = new CompletableTask<>(); + TaskRecord record = new TaskRecord<>(task, name, returnType); + this.openTasks.put(id, record); + return task; + }; + + return this.createAppropriateTask(taskFactory, options); + } + + private Task createAppropriateTask(TaskFactory taskFactory, TaskOptions options) { + // Retry policies and retry handlers will cause us to return a RetriableTask + if (options != null && (options.hasRetryPolicy() || options.hasRetryHandler())) { + return new RetriableTask(this, taskFactory, options.getRetryPolicy(), options.getRetryHandler()); + } else { + // Return a single vanilla task without any wrapper + return taskFactory.create(); + } + } + + public Task waitForExternalEvent(String name, Duration timeout, Class dataType) { + Helpers.throwIfOrchestratorComplete(this.isComplete); + Helpers.throwIfArgumentNull(name, "name"); + Helpers.throwIfArgumentNull(dataType, "dataType"); + + int id = this.sequenceNumber++; + + CompletableTask eventTask = new ExternalEventTask<>(name, id, timeout); + + // Check for a previously received event with the same name + for (OrchestratorService.HistoryEvent e : this.unprocessedEvents) { + OrchestratorService.EventRaisedEvent existing = e.getEventRaised(); + if (name.equalsIgnoreCase(existing.getName())) { + String rawEventData = existing.getInput().getValue(); + V data = this.dataConverter.deserialize(rawEventData, dataType); + eventTask.complete(data); + this.unprocessedEvents.remove(e); + return eventTask; + } + } + + boolean hasTimeout = !Helpers.isInfiniteTimeout(timeout); + + // Immediately cancel the task and return if the timeout is zero. + if (hasTimeout && timeout.isZero()) { + eventTask.cancel(); + return eventTask; + } + + // Add this task to the list of tasks waiting for an external event. + TaskRecord record = new TaskRecord<>(eventTask, name, dataType); + Queue> eventQueue = this.outstandingEvents.computeIfAbsent(name, k -> new LinkedList<>()); + eventQueue.add(record); + + // If a non-infinite timeout is specified, schedule an internal durable timer. + // If the timer expires and the external event task hasn't yet completed, we'll cancel the task. + if (hasTimeout) { + this.createTimer(timeout).future.thenRun(() -> { + if (!eventTask.isDone()) { + // Book-keeping - remove the task record for the canceled task + eventQueue.removeIf(t -> t.task == eventTask); + if (eventQueue.isEmpty()) { + this.outstandingEvents.remove(name); + } + + eventTask.cancel(); + } + }); + } + + return eventTask; + } + + private void handleTaskScheduled(OrchestratorService.HistoryEvent e) { + int taskId = e.getEventId(); + + OrchestratorService.TaskScheduledEvent taskScheduled = e.getTaskScheduled(); + + // The history shows that this orchestrator created a durable task in a previous execution. + // We can therefore remove it from the map of pending actions. If we can't find the pending + // action, then we assume a non-deterministic code violation in the orchestrator. + OrchestratorService.OrchestratorAction taskAction = this.pendingActions.remove(taskId); + if (taskAction == null) { + String message = String.format( + "Non-deterministic orchestrator detected: a history event scheduling an activity task with sequence " + + "ID %d and name '%s' was replayed but the current orchestrator implementation didn't actually " + + "schedule this task. Was a change made to the orchestrator code after this instance " + + "had already started running?", + taskId, + taskScheduled.getName()); + throw new NonDeterministicOrchestratorException(message); + } + } + + @SuppressWarnings("unchecked") + private void handleTaskCompleted(OrchestratorService.HistoryEvent e) { + OrchestratorService.TaskCompletedEvent completedEvent = e.getTaskCompleted(); + int taskId = completedEvent.getTaskScheduledId(); + TaskRecord record = this.openTasks.remove(taskId); + if (record == null) { + this.logger.warning("Discarding a potentially duplicate TaskCompleted event with ID = " + taskId); + return; + } + + String rawResult = completedEvent.getResult().getValue(); + + if (!this.isReplaying) { + // TODO: Structured logging + // TODO: Would it make more sense to put this log in the activity executor? + this.logger.fine(() -> String.format( + "%s: Activity '%s' (#%d) completed with serialized output: %s", + this.instanceId, + record.getTaskName(), + taskId, + rawResult != null ? rawResult : "(null)")); + + } + CompletableTask task = record.getTask(); + try { + Object result = this.dataConverter.deserialize(rawResult, record.getDataType()); + task.complete(result); + } catch (Exception ex) { + task.completeExceptionally(ex); + } + } + + private void handleTaskFailed(OrchestratorService.HistoryEvent e) { + OrchestratorService.TaskFailedEvent failedEvent = e.getTaskFailed(); + int taskId = failedEvent.getTaskScheduledId(); + TaskRecord record = this.openTasks.remove(taskId); + if (record == null) { + // TODO: Log a warning about a potential duplicate task completion event + return; + } + + FailureDetails details = new FailureDetails(failedEvent.getFailureDetails()); + + if (!this.isReplaying) { + // TODO: Log task failure, including the number of bytes in the result + } + + CompletableTask task = record.getTask(); + TaskFailedException exception = new TaskFailedException( + record.taskName, + taskId, + details); + task.completeExceptionally(exception); + } + + @SuppressWarnings("unchecked") + private void handleEventRaised(OrchestratorService.HistoryEvent e) { + OrchestratorService.EventRaisedEvent eventRaised = e.getEventRaised(); + String eventName = eventRaised.getName(); + + Queue> outstandingEventQueue = this.outstandingEvents.get(eventName); + if (outstandingEventQueue == null) { + // No code is waiting for this event. Buffer it in case user-code waits for it later. + this.unprocessedEvents.add(e); + return; + } + + // Signal the first waiter in the queue with this event payload. + TaskRecord matchingTaskRecord = outstandingEventQueue.remove(); + if (outstandingEventQueue.isEmpty()) { + this.outstandingEvents.remove(eventName); + } + String rawResult = eventRaised.getInput().getValue(); + CompletableTask task = matchingTaskRecord.getTask(); + try { + Object result = this.dataConverter.deserialize( + rawResult, + matchingTaskRecord.getDataType()); + task.complete(result); + } catch (Exception ex) { + task.completeExceptionally(ex); + } + } + + private void handleEventWhileSuspended(OrchestratorService.HistoryEvent historyEvent) { + if (historyEvent.getEventTypeCase() != OrchestratorService.HistoryEvent.EventTypeCase.EXECUTIONSUSPENDED) { + eventsWhileSuspended.offer(historyEvent); + } + } + + private void handleExecutionSuspended(OrchestratorService.HistoryEvent historyEvent) { + this.isSuspended = true; + } + + private void handleExecutionResumed(OrchestratorService.HistoryEvent historyEvent) { + this.isSuspended = false; + while (!eventsWhileSuspended.isEmpty()) { + this.processEvent(eventsWhileSuspended.poll()); + } + } + + public Task createTimer(Duration duration) { + Helpers.throwIfOrchestratorComplete(this.isComplete); + Helpers.throwIfArgumentNull(duration, "duration"); + + Instant finalFireAt = this.currentInstant.plus(duration); + return createTimer(finalFireAt); + } + + @Override + public Task createTimer(ZonedDateTime zonedDateTime) { + Helpers.throwIfOrchestratorComplete(this.isComplete); + Helpers.throwIfArgumentNull(zonedDateTime, "zonedDateTime"); + + Instant finalFireAt = zonedDateTime.toInstant(); + return createTimer(finalFireAt); + } + + private Task createTimer(Instant finalFireAt) { + return new TimerTask(finalFireAt); + } + + private CompletableTask createInstantTimer(int id, Instant fireAt) { + Timestamp ts = DataConverter.getTimestampFromInstant(fireAt); + this.pendingActions.put(id, OrchestratorService.OrchestratorAction.newBuilder() + .setId(id) + .setCreateTimer(OrchestratorService.CreateTimerAction.newBuilder().setFireAt(ts)) + .build()); + + if (!this.isReplaying) { + logger.finer(() -> String.format("Creating Instant Timer with id: %s, fireAt: %s ", id, fireAt)); + } + + CompletableTask timerTask = new CompletableTask<>(); + TaskRecord record = new TaskRecord<>(timerTask, "(timer)", Void.class); + this.openTasks.put(id, record); + return timerTask; + } + + private void handleTimerCreated(OrchestratorService.HistoryEvent e) { + int timerEventId = e.getEventId(); + if (timerEventId == -100) { + // Infrastructure timer used by the dispatcher to break transactions into multiple batches + return; + } + + OrchestratorService.TimerCreatedEvent timerCreatedEvent = e.getTimerCreated(); + + // The history shows that this orchestrator created a durable timer in a previous execution. + // We can therefore remove it from the map of pending actions. If we can't find the pending + // action, then we assume a non-deterministic code violation in the orchestrator. + OrchestratorService.OrchestratorAction timerAction = this.pendingActions.remove(timerEventId); + if (timerAction == null) { + String message = String.format( + "Non-deterministic orchestrator detected: a history event creating a timer with ID %d and " + + "fire-at time %s was replayed but the current orchestrator implementation didn't actually create " + + "this timer. Was a change made to the orchestrator code after this instance " + + "had already started running?", + timerEventId, + DataConverter.getInstantFromTimestamp(timerCreatedEvent.getFireAt())); + throw new NonDeterministicOrchestratorException(message); + } + } + + public void handleTimerFired(OrchestratorService.HistoryEvent e) { + OrchestratorService.TimerFiredEvent timerFiredEvent = e.getTimerFired(); + int timerEventId = timerFiredEvent.getTimerId(); + TaskRecord record = this.openTasks.remove(timerEventId); + if (record == null) { + // TODO: Log a warning about a potential duplicate timer fired event + return; + } + + if (!this.isReplaying) { + this.logger.finer(() -> + String.format("Firing timer by completing task: %s expected fire at time: %s", timerEventId, + Instant.ofEpochSecond(timerFiredEvent.getFireAt().getSeconds(), + timerFiredEvent.getFireAt().getNanos()))); + } + + CompletableTask task = record.getTask(); + task.complete(null); + } + + private void handleSubOrchestrationCreated(OrchestratorService.HistoryEvent e) { + int taskId = e.getEventId(); + OrchestratorService.SubOrchestrationInstanceCreatedEvent subOrchestrationInstanceCreated = + e.getSubOrchestrationInstanceCreated(); + OrchestratorService.OrchestratorAction taskAction = this.pendingActions.remove(taskId); + if (taskAction == null) { + String message = String.format( + "Non-deterministic orchestrator detected: a history event scheduling an sub-orchestration task " + + "with sequence ID %d and name '%s' was replayed but the current orchestrator implementation didn't " + + "actually schedule this task. Was a change made to the orchestrator code after this instance had " + + "already started running?", + taskId, + subOrchestrationInstanceCreated.getName()); + throw new NonDeterministicOrchestratorException(message); + } + } + + private void handleSubOrchestrationCompleted(OrchestratorService.HistoryEvent e) { + OrchestratorService.SubOrchestrationInstanceCompletedEvent subOrchestrationInstanceCompletedEvent = + e.getSubOrchestrationInstanceCompleted(); + int taskId = subOrchestrationInstanceCompletedEvent.getTaskScheduledId(); + TaskRecord record = this.openTasks.remove(taskId); + if (record == null) { + this.logger.warning("Discarding a potentially duplicate SubOrchestrationInstanceCompleted " + + "event with ID = " + taskId); + return; + } + String rawResult = subOrchestrationInstanceCompletedEvent.getResult().getValue(); + + if (!this.isReplaying) { + // TODO: Structured logging + // TODO: Would it make more sense to put this log in the activity executor? + this.logger.fine(() -> String.format( + "%s: Sub-orchestrator '%s' (#%d) completed with serialized output: %s", + this.instanceId, + record.getTaskName(), + taskId, + rawResult != null ? rawResult : "(null)")); + + } + CompletableTask task = record.getTask(); + try { + Object result = this.dataConverter.deserialize(rawResult, record.getDataType()); + task.complete(result); + } catch (Exception ex) { + task.completeExceptionally(ex); + } + } + + private void handleSubOrchestrationFailed(OrchestratorService.HistoryEvent e) { + OrchestratorService.SubOrchestrationInstanceFailedEvent subOrchestrationInstanceFailedEvent = + e.getSubOrchestrationInstanceFailed(); + int taskId = subOrchestrationInstanceFailedEvent.getTaskScheduledId(); + TaskRecord record = this.openTasks.remove(taskId); + if (record == null) { + // TODO: Log a warning about a potential duplicate task completion event + return; + } + + FailureDetails details = new FailureDetails(subOrchestrationInstanceFailedEvent.getFailureDetails()); + + if (!this.isReplaying) { + // TODO: Log task failure, including the number of bytes in the result + } + + CompletableTask task = record.getTask(); + TaskFailedException exception = new TaskFailedException( + record.taskName, + taskId, + details); + task.completeExceptionally(exception); + } + + private void handleExecutionTerminated(OrchestratorService.HistoryEvent e) { + OrchestratorService.ExecutionTerminatedEvent executionTerminatedEvent = e.getExecutionTerminated(); + this.completeInternal(executionTerminatedEvent.getInput().getValue(), null, + OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_TERMINATED); + } + + @Override + public void complete(Object output) { + if (this.continuedAsNew) { + this.completeInternal(this.continuedAsNewInput, + OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW); + } else { + this.completeInternal(output, OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + } + } + + public void fail(FailureDetails failureDetails) { + // TODO: How does a parent orchestration use the output to construct an exception? + this.completeInternal(null, failureDetails, + OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); + } + + private void completeInternal(Object output, OrchestratorService.OrchestrationStatus runtimeStatus) { + String resultAsJson = TaskOrchestrationExecutor.this.dataConverter.serialize(output); + this.completeInternal(resultAsJson, null, runtimeStatus); + } + + private void completeInternal( + @Nullable String rawOutput, + @Nullable FailureDetails failureDetails, + OrchestratorService.OrchestrationStatus runtimeStatus) { + Helpers.throwIfOrchestratorComplete(this.isComplete); + + + OrchestratorService.CompleteOrchestrationAction.Builder builder = OrchestratorService.CompleteOrchestrationAction + .newBuilder(); + builder.setOrchestrationStatus(runtimeStatus); + + if (rawOutput != null) { + builder.setResult(StringValue.of(rawOutput)); + } + + if (failureDetails != null) { + builder.setFailureDetails(failureDetails.toProto()); + } + + if (this.continuedAsNew && this.preserveUnprocessedEvents) { + addCarryoverEvents(builder); + } + + if (!this.isReplaying) { + // TODO: Log completion, including the number of bytes in the output + } + + int id = this.sequenceNumber++; + OrchestratorService.OrchestratorAction action = OrchestratorService.OrchestratorAction.newBuilder() + .setId(id) + .setCompleteOrchestration(builder.build()) + .build(); + this.pendingActions.put(id, action); + this.isComplete = true; + } + + private void addCarryoverEvents(OrchestratorService.CompleteOrchestrationAction.Builder builder) { + // Add historyEvent in the unprocessedEvents buffer + // Add historyEvent in the new event list that haven't been added to the buffer. + // We don't check the event in the pass event list to avoid duplicated events. + Set externalEvents = new HashSet<>(this.unprocessedEvents); + List newEvents = this.historyEventPlayer.getNewEvents(); + int currentHistoryIndex = this.historyEventPlayer.getCurrentHistoryIndex(); + + // Only add events that haven't been processed to the carryOverEvents + // currentHistoryIndex will point to the first unprocessed event + for (int i = currentHistoryIndex; i < newEvents.size(); i++) { + OrchestratorService.HistoryEvent historyEvent = newEvents.get(i); + if (historyEvent.getEventTypeCase() == OrchestratorService.HistoryEvent.EventTypeCase.EVENTRAISED) { + externalEvents.add(historyEvent); + } + } + + externalEvents.forEach(builder::addCarryoverEvents); + } + + private boolean waitingForEvents() { + return this.outstandingEvents.size() > 0; + } + + private boolean processNextEvent() { + return this.historyEventPlayer.moveNext(); + } + + private void processEvent(OrchestratorService.HistoryEvent e) { + boolean overrideSuspension = e.getEventTypeCase() + == OrchestratorService.HistoryEvent.EventTypeCase.EXECUTIONRESUMED + || e.getEventTypeCase() == OrchestratorService.HistoryEvent.EventTypeCase.EXECUTIONTERMINATED; + if (this.isSuspended && !overrideSuspension) { + this.handleEventWhileSuspended(e); + } else { + this.logger.fine(() -> this.instanceId + ": Processing event: " + e.getEventTypeCase()); + switch (e.getEventTypeCase()) { + case ORCHESTRATORSTARTED: + Instant instant = DataConverter.getInstantFromTimestamp(e.getTimestamp()); + this.setCurrentInstant(instant); + this.logger.fine(() -> this.instanceId + ": Workflow orchestrator started"); + break; + case ORCHESTRATORCOMPLETED: + // No action needed + this.logger.fine(() -> this.instanceId + ": Workflow orchestrator completed"); + break; + case EXECUTIONSTARTED: + OrchestratorService.ExecutionStartedEvent executionStarted = e.getExecutionStarted(); + this.setName(executionStarted.getName()); + this.setInput(executionStarted.getInput().getValue()); + this.setInstanceId(executionStarted.getOrchestrationInstance().getInstanceId()); + this.logger.fine(() -> this.instanceId + ": Workflow execution started"); + this.setAppId(e.getRouter().getSourceAppID()); + + // Create and invoke the workflow orchestrator + TaskOrchestrationFactory factory = TaskOrchestrationExecutor.this.orchestrationFactories + .get(executionStarted.getName()); + if (factory == null) { + // Try getting the default orchestrator + factory = TaskOrchestrationExecutor.this.orchestrationFactories.get("*"); + } + // TODO: Throw if the factory is null (orchestration by that name doesn't exist) + if (factory == null) { + throw new IllegalStateException("No factory found for orchestrator: " + executionStarted.getName()); + } + + TaskOrchestration orchestrator = factory.create(); + orchestrator.run(this); + break; + case EXECUTIONCOMPLETED: + this.logger.fine(() -> this.instanceId + ": Workflow execution completed"); + break; + case EXECUTIONTERMINATED: + this.handleExecutionTerminated(e); + break; + case TASKSCHEDULED: + this.handleTaskScheduled(e); + break; + case TASKCOMPLETED: + this.handleTaskCompleted(e); + break; + case TASKFAILED: + this.handleTaskFailed(e); + break; + case TIMERCREATED: + this.handleTimerCreated(e); + break; + case TIMERFIRED: + this.handleTimerFired(e); + break; + case SUBORCHESTRATIONINSTANCECREATED: + this.handleSubOrchestrationCreated(e); + break; + case SUBORCHESTRATIONINSTANCECOMPLETED: + this.handleSubOrchestrationCompleted(e); + break; + case SUBORCHESTRATIONINSTANCEFAILED: + this.handleSubOrchestrationFailed(e); + break; + case EVENTRAISED: + this.handleEventRaised(e); + break; + case EXECUTIONSUSPENDED: + this.handleExecutionSuspended(e); + break; + case EXECUTIONRESUMED: + this.handleExecutionResumed(e); + break; + default: + throw new IllegalStateException("Don't know how to handle history type " + e.getEventTypeCase()); + } + } + } + + private class TaskRecord { + private final CompletableTask task; + private final String taskName; + private final Class dataType; + + public TaskRecord(CompletableTask task, String taskName, Class dataType) { + this.task = task; + this.taskName = taskName; + this.dataType = dataType; + } + + public CompletableTask getTask() { + return this.task; + } + + public String getTaskName() { + return this.taskName; + } + + public Class getDataType() { + return this.dataType; + } + } + + private class OrchestrationHistoryIterator { + private final List pastEvents; + private final List newEvents; + + private List currentHistoryList; + private int currentHistoryIndex; + + public OrchestrationHistoryIterator(List pastEvents, + List newEvents) { + this.pastEvents = pastEvents; + this.newEvents = newEvents; + this.currentHistoryList = pastEvents; + } + + public boolean moveNext() { + if (this.currentHistoryList == pastEvents && this.currentHistoryIndex >= pastEvents.size()) { + // Move forward to the next list + this.currentHistoryList = this.newEvents; + this.currentHistoryIndex = 0; + + ContextImplTask.this.setDoneReplaying(); + } + + if (this.currentHistoryList == this.newEvents && this.currentHistoryIndex >= this.newEvents.size()) { + // We're done enumerating the history + return false; + } + + // Process the next event in the history + OrchestratorService.HistoryEvent next = this.currentHistoryList.get(this.currentHistoryIndex++); + ContextImplTask.this.processEvent(next); + return true; + } + + List getNewEvents() { + return this.newEvents; + } + + int getCurrentHistoryIndex() { + return this.currentHistoryIndex; + } + } + + private class TimerTask extends CompletableTask { + private Instant finalFireAt; + CompletableTask task; + + public TimerTask(Instant finalFireAt) { + super(); + CompletableTask firstTimer = createTimerTask(finalFireAt); + CompletableFuture timerChain = createTimerChain(finalFireAt, firstTimer.future); + this.task = new CompletableTask<>(timerChain); + this.finalFireAt = finalFireAt; + } + + // For a short timer (less than maximumTimerInterval), once the currentFuture completes, + // we must have reached finalFireAt, so we return and no more sub-timers are created. For a long timer + // (more than maximumTimerInterval), once a given currentFuture completes, we check if we have not yet + // reached finalFireAt. If that is the case, we create a new sub-timer task and make a recursive call on + // that new sub-timer task so that once it completes, another sub-timer task is created + // if necessary. Otherwise, we return and no more sub-timers are created. + private CompletableFuture createTimerChain(Instant finalFireAt, CompletableFuture currentFuture) { + return currentFuture.thenRun(() -> { + Instant currentInstsanceMinusNanos = currentInstant.minusNanos(currentInstant.getNano()); + Instant finalFireAtMinusNanos = finalFireAt.minusNanos(finalFireAt.getNano()); + if (currentInstsanceMinusNanos.compareTo(finalFireAtMinusNanos) >= 0) { + return; + } + Task nextTimer = createTimerTask(finalFireAt); + createTimerChain(finalFireAt, nextTimer.future); + }); + } + + private CompletableTask createTimerTask(Instant finalFireAt) { + CompletableTask nextTimer; + Duration remainingTime = Duration.between(currentInstant, finalFireAt); + if (remainingTime.compareTo(maximumTimerInterval) > 0) { + Instant nextFireAt = currentInstant.plus(maximumTimerInterval); + nextTimer = createInstantTimer(sequenceNumber++, nextFireAt); + } else { + nextTimer = createInstantTimer(sequenceNumber++, finalFireAt); + } + nextTimer.setParentTask(this); + return nextTimer; + } + + private void handleSubTimerSuccess() { + // check if it is the last timer + Instant currentInstantMinusNanos = currentInstant.minusNanos(currentInstant.getNano()); + Instant finalFireAtMinusNanos = finalFireAt.minusNanos(finalFireAt.getNano()); + if (currentInstantMinusNanos.compareTo(finalFireAtMinusNanos) >= 0) { + this.complete(null); + } + } + + @Override + public Void await() { + return this.task.await(); + } + + } + + private class ExternalEventTask extends CompletableTask { + private final String eventName; + private final Duration timeout; + private final int taskId; + + public ExternalEventTask(String eventName, int taskId, Duration timeout) { + this.eventName = eventName; + this.taskId = taskId; + this.timeout = timeout; + } + + // TODO: Shouldn't this be throws TaskCanceledException? + @Override + protected void handleException(Throwable e) { + // Cancellation is caused by user-specified timeouts + if (e instanceof CancellationException) { + String message = String.format( + "Timeout of %s expired while waiting for an event named '%s' (ID = %d).", + this.timeout, + this.eventName, + this.taskId); + throw new TaskCanceledException(message, this.eventName, this.taskId); + } + + super.handleException(e); + } + } + + // Task implementation that implements a retry policy + private class RetriableTask extends CompletableTask { + private final RetryPolicy policy; + private final RetryHandler handler; + private final TaskOrchestrationContext context; + private final Instant firstAttempt; + private final TaskFactory taskFactory; + + private FailureDetails lastFailure; + private Duration totalRetryTime; + private Instant startTime; + private int attemptNumber; + private Task childTask; + + public RetriableTask(TaskOrchestrationContext context, TaskFactory taskFactory, RetryPolicy policy) { + this(context, taskFactory, policy, null); + } + + public RetriableTask(TaskOrchestrationContext context, TaskFactory taskFactory, RetryHandler handler) { + this(context, taskFactory, null, handler); + } + + private RetriableTask( + TaskOrchestrationContext context, + TaskFactory taskFactory, + @Nullable RetryPolicy retryPolicy, + @Nullable RetryHandler retryHandler) { + this.context = context; + this.taskFactory = taskFactory; + this.policy = retryPolicy; + this.handler = retryHandler; + this.firstAttempt = context.getCurrentInstant(); + this.totalRetryTime = Duration.ZERO; + this.createChildTask(taskFactory); + } + + // Every RetriableTask will have a CompletableTask as a child task. + private void createChildTask(TaskFactory taskFactory) { + CompletableTask childTask = (CompletableTask) taskFactory.create(); + this.setChildTask(childTask); + childTask.setParentTask(this); + } + + public void setChildTask(Task childTask) { + this.childTask = childTask; + } + + public Task getChildTask() { + return this.childTask; + } + + void handleChildSuccess(V result) { + this.complete(result); + } + + void handleChildException(Throwable ex) { + tryRetry((TaskFailedException) ex); + } + + void init() { + this.startTime = this.startTime == null ? this.context.getCurrentInstant() : this.startTime; + this.attemptNumber++; + } + + public void tryRetry(TaskFailedException ex) { + this.lastFailure = ex.getErrorDetails(); + if (!this.shouldRetry()) { + this.completeExceptionally(ex); + return; + } + + // Overflow/runaway retry protection + if (this.attemptNumber == Integer.MAX_VALUE) { + this.completeExceptionally(ex); + return; + } + + Duration delay = this.getNextDelay(); + if (!delay.isZero() && !delay.isNegative()) { + // Use a durable timer to create the delay between retries + this.context.createTimer(delay).await(); + } + + this.totalRetryTime = Duration.between(this.startTime, this.context.getCurrentInstant()); + this.createChildTask(this.taskFactory); + this.await(); + } + + @Override + public V await() { + this.init(); + // when awaiting the first child task, we will continue iterating over the history until a result is found + // for that task. If the result is an exception, the child task will invoke "handleChildException" on this + // object, which awaits a timer, *re-sets the current child task to correspond to a retry of this task*, + // and then awaits that child. + // This logic continues until either the operation succeeds, or are our retry quota is met. + // At that point, we break the `await()` on the child task. + // Therefore, once we return from the following `await`, + // we just need to await again on the *current* child task to obtain the result of this task + try { + this.getChildTask().await(); + } catch (OrchestratorBlockedException ex) { + throw ex; + } catch (Exception ignored) { + // ignore the exception from previous child tasks. + // Only needs to return result from the last child task, which is on next line. + } + // Always return the last child task result. + return this.getChildTask().await(); + } + + private boolean shouldRetry() { + if (this.lastFailure.isNonRetriable()) { + logger.warning("Not performing any retries because the error is non retriable"); + + return false; + } + + if (this.policy == null && this.handler == null) { + // We should never get here, but if we do, returning false is the natural behavior. + return false; + } + + RetryContext retryContext = new RetryContext( + this.context, + this.attemptNumber, + this.lastFailure, + this.totalRetryTime); + + // These must default to true if not provided, so it is possible to use only one of them at a time + boolean shouldRetryBasedOnPolicy = this.policy != null ? this.shouldRetryBasedOnPolicy() : true; + boolean shouldRetryBasedOnHandler = this.handler != null ? this.handler.handle(retryContext) : true; + + // Only log when not replaying, so only the current attempt is logged and not all previous attempts. + if (!this.context.getIsReplaying()) { + if (this.policy != null) { + logger.fine(() -> String.format("shouldRetryBasedOnPolicy: %s", shouldRetryBasedOnPolicy)); + } + + if (this.handler != null) { + logger.fine(() -> String.format("shouldRetryBasedOnHandler: %s", shouldRetryBasedOnHandler)); + } + } + + return shouldRetryBasedOnPolicy && shouldRetryBasedOnHandler; + } + + private boolean shouldRetryBasedOnPolicy() { + // Only log when not replaying, so only the current attempt is logged and not all previous attempts. + if (!this.context.getIsReplaying()) { + logger.fine(() -> String.format("Retry Policy: %d retries out of total %d performed ", this.attemptNumber, + this.policy.getMaxNumberOfAttempts())); + } + + if (this.attemptNumber >= this.policy.getMaxNumberOfAttempts()) { + // Max number of attempts exceeded + return false; + } + + // Duration.ZERO is interpreted as no maximum timeout + Duration retryTimeout = this.policy.getRetryTimeout(); + if (retryTimeout.compareTo(Duration.ZERO) > 0) { + Instant retryExpiration = this.firstAttempt.plus(retryTimeout); + if (this.context.getCurrentInstant().compareTo(retryExpiration) >= 0) { + // Max retry timeout exceeded + return false; + } + } + + // Keep retrying + return true; + } + + private Duration getNextDelay() { + if (this.policy != null) { + long maxDelayInMillis = this.policy.getMaxRetryInterval().toMillis(); + + long nextDelayInMillis; + try { + nextDelayInMillis = Math.multiplyExact( + this.policy.getFirstRetryInterval().toMillis(), + (long) Helpers.powExact(this.policy.getBackoffCoefficient(), this.attemptNumber)); + } catch (ArithmeticException overflowException) { + if (maxDelayInMillis > 0) { + return this.policy.getMaxRetryInterval(); + } else { + // If no maximum is specified, just throw + throw new ArithmeticException("The retry policy calculation resulted in an arithmetic " + + "overflow and no max retry interval was configured."); + } + } + + // NOTE: A max delay of zero or less is interpreted to mean no max delay + if (nextDelayInMillis > maxDelayInMillis && maxDelayInMillis > 0) { + return this.policy.getMaxRetryInterval(); + } else { + return Duration.ofMillis(nextDelayInMillis); + } + } + + // If there's no declarative retry policy defined, then the custom code retry handler + // is responsible for implementing any delays between retry attempts. + return Duration.ZERO; + } + } + + private class CompoundTask extends CompletableTask { + + List> subTasks; + + CompoundTask(List> subtasks, CompletableFuture future) { + super(future); + this.subTasks = subtasks; + } + + @Override + public U await() { + this.initSubTasks(); + return super.await(); + } + + private void initSubTasks() { + for (Task subTask : this.subTasks) { + if (subTask instanceof RetriableTask) { + ((RetriableTask) subTask).init(); + } + } + } + } + + private class CompletableTask extends Task { + private Task parentTask; + + public CompletableTask() { + this(new CompletableFuture<>()); + } + + CompletableTask(CompletableFuture future) { + super(future); + } + + public void setParentTask(Task parentTask) { + this.parentTask = parentTask; + } + + public Task getParentTask() { + return this.parentTask; + } + + @Override + public V await() { + do { + // If the future is done, return its value right away + if (this.future.isDone()) { + try { + return this.future.get(); + } catch (ExecutionException e) { + // rethrow if it's ContinueAsNewInterruption + if (e.getCause() instanceof ContinueAsNewInterruption) { + throw (ContinueAsNewInterruption) e.getCause(); + } + this.handleException(e.getCause()); + } catch (Exception e) { + this.handleException(e); + } + } + } while (processNextEvent()); + + // There's no more history left to replay and the current task is still not completed. This is normal. + // The OrchestratorBlockedException exception allows us to yield the current thread back to the executor so + // that we can send the current set of actions back to the worker and wait for new events to come in. + // This is *not* an exception - it's a normal part of orchestrator control flow. + throw new OrchestratorBlockedException( + "The orchestrator is blocked and waiting for new inputs. " + + "This Throwable should never be caught by user code."); + } + + private boolean processNextEvent() { + try { + return ContextImplTask.this.processNextEvent(); + } catch (OrchestratorBlockedException | ContinueAsNewInterruption exception) { + throw exception; + } catch (Exception e) { + // ignore + // + // We ignore the exception. Any Durable Task exceptions thrown here can be obtained when calling + //{code#future.get()} in the implementation of 'await'. We defer to that loop to handle the exception. + // + } + // Any exception happen we return true so that we will enter to the do-while block for the last time. + return true; + } + + @Override + public CompletableTask thenApply(Function fn) { + CompletableFuture newFuture = this.future.thenApply(fn); + return new CompletableTask<>(newFuture); + } + + @Override + public Task thenAccept(Consumer fn) { + CompletableFuture newFuture = this.future.thenAccept(fn); + return new CompletableTask<>(newFuture); + } + + protected void handleException(Throwable e) { + if (e instanceof TaskFailedException) { + throw (TaskFailedException) e; + } + + if (e instanceof CompositeTaskFailedException) { + throw (CompositeTaskFailedException) e; + } + + if (e instanceof DataConverter.DataConverterException) { + throw (DataConverter.DataConverterException) e; + } + + throw new RuntimeException("Unexpected failure in the task execution", e); + } + + @Override + public boolean isDone() { + return this.future.isDone(); + } + + public boolean complete(V value) { + Task parentTask = this.getParentTask(); + boolean result = this.future.complete(value); + if (parentTask instanceof RetriableTask) { + // notify parent task + ((RetriableTask) parentTask).handleChildSuccess(value); + } + if (parentTask instanceof TimerTask) { + // notify parent task + ((TimerTask) parentTask).handleSubTimerSuccess(); + } + return result; + } + + private boolean cancel() { + return this.future.cancel(true); + } + + public boolean completeExceptionally(Throwable ex) { + Task parentTask = this.getParentTask(); + boolean result = this.future.completeExceptionally(ex); + if (parentTask instanceof RetriableTask) { + // notify parent task + ((RetriableTask) parentTask).handleChildException(ex); + } + return result; + } + } + } + + @FunctionalInterface + private interface TaskFactory { + Task create(); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationFactory.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationFactory.java new file mode 100644 index 000000000..274813b69 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestrationFactory.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +/** + * Factory interface for producing {@link TaskOrchestration} implementations. + */ +public interface TaskOrchestrationFactory { + /** + * Gets the name of the orchestration this factory creates. + * + * @return the name of the orchestration + */ + String getName(); + + /** + * Creates a new instance of {@link TaskOrchestration}. + * + * @return the created orchestration instance + */ + TaskOrchestration create(); +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestratorResult.java b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestratorResult.java new file mode 100644 index 000000000..705a41d5c --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/TaskOrchestratorResult.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import io.dapr.durabletask.implementation.protobuf.OrchestratorService; + +import java.util.Collection; +import java.util.Collections; + +final class TaskOrchestratorResult { + + private final Collection actions; + + private final String customStatus; + + public TaskOrchestratorResult(Collection actions, String customStatus) { + this.actions = Collections.unmodifiableCollection(actions); + ; + this.customStatus = customStatus; + } + + public Collection getActions() { + return this.actions; + } + + public String getCustomStatus() { + return this.customStatus; + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/interruption/ContinueAsNewInterruption.java b/durabletask-client/src/main/java/io/dapr/durabletask/interruption/ContinueAsNewInterruption.java new file mode 100644 index 000000000..e95c51157 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/interruption/ContinueAsNewInterruption.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask.interruption; + +import io.dapr.durabletask.TaskOrchestrationContext; + +/** + * Control flow {@code Throwable} class for orchestrator when invoke {@link TaskOrchestrationContext#continueAsNew}. + * This {@code Throwable} must never be caught by user + * code. + * + *

{@code ContinueAsNewInterruption} is thrown when an orchestrator calls + * {@link TaskOrchestrationContext#continueAsNew}. + * Catching {@code ContinueAsNewInterruption} in user code could prevent the orchestration from saving + * state and scheduling new tasks, resulting in the orchestration getting stuck.

+ */ +public class ContinueAsNewInterruption extends RuntimeException { + public ContinueAsNewInterruption(String message) { + super(message); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/interruption/OrchestratorBlockedException.java b/durabletask-client/src/main/java/io/dapr/durabletask/interruption/OrchestratorBlockedException.java new file mode 100644 index 000000000..7eff5248f --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/interruption/OrchestratorBlockedException.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask.interruption; + +import io.dapr.durabletask.Task; + +/** + * Control flow {@code Throwable} class for orchestrator functions. This {@code Throwable} must never be caught by user + * code. + * + *

{@code OrchestratorBlockedException} is thrown when an orchestrator calls {@link Task#await} on an uncompleted + * task. The purpose of throwing in this way is to halt execution of the orchestrator to save the current state and + * commit any side effects. Catching {@code OrchestratorBlockedException} in user code could prevent the orchestration + * from saving state and scheduling new tasks, resulting in the orchestration getting stuck.

+ */ +public final class OrchestratorBlockedException extends RuntimeException { + public OrchestratorBlockedException(String message) { + super(message); + } +} diff --git a/durabletask-client/src/main/java/io/dapr/durabletask/util/UuidGenerator.java b/durabletask-client/src/main/java/io/dapr/durabletask/util/UuidGenerator.java new file mode 100644 index 000000000..a55ed5fb1 --- /dev/null +++ b/durabletask-client/src/main/java/io/dapr/durabletask/util/UuidGenerator.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask.util; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.UUID; + +/** + * Utility class for generating UUIDs. + * + */ +public class UuidGenerator { + + /** + * Generates a UUID. + * @param version for the UUID generation + * @param algorithm to be used + * @param namespace for the UUID generation + * @param name for the UUID generation + * @return the generated UUID + */ + public static UUID generate(int version, String algorithm, UUID namespace, String name) { + + MessageDigest hasher = hasher(algorithm); + + if (namespace != null) { + ByteBuffer ns = ByteBuffer.allocate(16); + ns.putLong(namespace.getMostSignificantBits()); + ns.putLong(namespace.getLeastSignificantBits()); + hasher.update(ns.array()); + } + + hasher.update(name.getBytes(StandardCharsets.UTF_8)); + ByteBuffer hash = ByteBuffer.wrap(hasher.digest()); + + final long msb = (hash.getLong() & 0xffffffffffff0fffL) | (version & 0x0f) << 12; + final long lsb = (hash.getLong() & 0x3fffffffffffffffL) | 0x8000000000000000L; + + return new UUID(msb, lsb); + } + + private static MessageDigest hasher(String algorithm) { + try { + return MessageDigest.getInstance(algorithm); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(String.format("%s not supported.", algorithm)); + } + } +} diff --git a/durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskClientIT.java b/durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskClientIT.java new file mode 100644 index 000000000..85c7de0e4 --- /dev/null +++ b/durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskClientIT.java @@ -0,0 +1,1785 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ +package io.dapr.durabletask; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReferenceArray; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * These integration tests are designed to exercise the core, high-level features of + * the Durable Task programming model. + *

+ * These tests currently require a sidecar process to be + * running on the local machine (the sidecar is what accepts the client operations and + * sends invocation instructions to the DurableTaskWorker). + */ +@Tag("integration") +public class DurableTaskClientIT extends IntegrationTestBase { + static final Duration defaultTimeout = Duration.ofSeconds(100); + // All tests that create a server should save it to this variable for proper shutdown + private DurableTaskGrpcWorker server; + + + @Test + void emptyOrchestration() throws TimeoutException { + final String orchestratorName = "EmptyOrchestration"; + final String input = "Hello " + Instant.now(); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> ctx.complete(ctx.getInput(String.class))) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, input); + OrchestrationMetadata instance = client.waitForInstanceCompletion( + instanceId, + defaultTimeout, + true); + + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + assertEquals(input, instance.readInputAs(String.class)); + assertEquals(input, instance.readOutputAs(String.class)); + } + } + + @Test + void singleTimer() throws IOException, TimeoutException { + final String orchestratorName = "SingleTimer"; + final Duration delay = Duration.ofSeconds(3); + AtomicReferenceArray timestamps = new AtomicReferenceArray<>(2); + AtomicInteger counter = new AtomicInteger(); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + timestamps.set(counter.get(), LocalDateTime.now()); + counter.incrementAndGet(); + ctx.createTimer(delay).await(); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + Duration timeout = delay.plus(defaultTimeout); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, timeout, false); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + // Verify that the delay actually happened + long expectedCompletionSecond = instance.getCreatedAt().plus(delay).getEpochSecond(); + long actualCompletionSecond = instance.getLastUpdatedAt().getEpochSecond(); + assertTrue(expectedCompletionSecond <= actualCompletionSecond); + + // Verify that the correct number of timers were created + // This should yield 2 (first invocation + replay invocations for internal timers) + assertEquals(2, counter.get()); + + // Verify that each timer is the expected length + int[] secondsElapsed = new int[1]; + for (int i = 0; i < timestamps.length() - 1; i++) { + secondsElapsed[i] = timestamps.get(i + 1).getSecond() - timestamps.get(i).getSecond(); + } + assertEquals(3, secondsElapsed[0]); + + } + } + + + @Test + void loopWithTimer() throws IOException, TimeoutException { + final String orchestratorName = "LoopWithTimer"; + final Duration delay = Duration.ofSeconds(2); + AtomicReferenceArray timestamps = new AtomicReferenceArray<>(4); + AtomicInteger counter = new AtomicInteger(); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + for (int i = 0; i < 3; i++) { + if (!ctx.getIsReplaying()) { + timestamps.set(counter.get(), LocalDateTime.now()); + counter.incrementAndGet(); + } + ctx.createTimer(delay).await(); + } + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + Duration timeout = delay.plus(defaultTimeout); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, timeout, false); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + // Verify that the delay actually happened + long expectedCompletionSecond = instance.getCreatedAt().plus(delay).getEpochSecond(); + long actualCompletionSecond = instance.getLastUpdatedAt().getEpochSecond(); + assertTrue(expectedCompletionSecond <= actualCompletionSecond); + + // Verify that the correct number of timers were created + assertEquals(3, counter.get()); + + // Verify that each timer is the expected length + int[] secondsElapsed = new int[timestamps.length()]; + for (int i = 0; i < timestamps.length() - 1; i++) { + if (timestamps.get(i + 1) != null && timestamps.get(i) != null) { + secondsElapsed[i] = timestamps.get(i + 1).getSecond() - timestamps.get(i).getSecond(); + } else { + secondsElapsed[i] = -1; + } + } + assertEquals(2, secondsElapsed[0]); + assertEquals(2, secondsElapsed[1]); + assertEquals(-1, secondsElapsed[2]); + + + } + } + + @Test + void loopWithWaitForEvent() throws IOException, TimeoutException { + final String orchestratorName = "LoopWithTimer"; + final Duration delay = Duration.ofSeconds(2); + AtomicReferenceArray timestamps = new AtomicReferenceArray<>(4); + AtomicInteger counter = new AtomicInteger(); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + for (int i = 0; i < 4; i++) { + try { + ctx.waitForExternalEvent("HELLO", delay).await(); + } catch (TaskCanceledException tce) { + if (!ctx.getIsReplaying()) { + timestamps.set(counter.get(), LocalDateTime.now()); + counter.incrementAndGet(); + } + + } + } + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + Duration timeout = delay.plus(defaultTimeout); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, timeout, false); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + // Verify that the delay actually happened + long expectedCompletionSecond = instance.getCreatedAt().plus(delay).getEpochSecond(); + long actualCompletionSecond = instance.getLastUpdatedAt().getEpochSecond(); + assertTrue(expectedCompletionSecond <= actualCompletionSecond); + + // Verify that the correct number of timers were created + assertEquals(4, counter.get()); + + // Verify that each timer is the expected length + int[] secondsElapsed = new int[timestamps.length()]; + for (int i = 0; i < timestamps.length() - 1; i++) { + if (timestamps.get(i + 1) != null && timestamps.get(i) != null) { + secondsElapsed[i] = timestamps.get(i + 1).getSecond() - timestamps.get(i).getSecond(); + } else { + secondsElapsed[i] = -1; + } + } + assertEquals(2, secondsElapsed[0]); + assertEquals(2, secondsElapsed[1]); + assertEquals(2, secondsElapsed[2]); + assertEquals(0, secondsElapsed[3]); + + + } + } + + @Test + void longTimer() throws TimeoutException { + final String orchestratorName = "LongTimer"; + final Duration delay = Duration.ofSeconds(7); + AtomicInteger counter = new AtomicInteger(); + AtomicReferenceArray timestamps = new AtomicReferenceArray<>(4); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + timestamps.set(counter.get(), LocalDateTime.now()); + counter.incrementAndGet(); + ctx.createTimer(delay).await(); + }) + .setMaximumTimerInterval(Duration.ofSeconds(3)) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + Duration timeout = delay.plus(defaultTimeout); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, timeout, false); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus(), + String.format("Orchestration failed with error: %s", instance.getFailureDetails().getErrorMessage())); + + // Verify that the delay actually happened + long expectedCompletionSecond = instance.getCreatedAt().plus(delay).getEpochSecond(); + long actualCompletionSecond = instance.getLastUpdatedAt().getEpochSecond(); + assertTrue(expectedCompletionSecond <= actualCompletionSecond); + + // Verify that the correct number of timers were created + // This should yield 4 (first invocation + replay invocations for internal timers 3s + 3s + 1s) + assertEquals(4, counter.get()); + + // Verify that each timer is the expected length + int[] secondsElapsed = new int[3]; + for (int i = 0; i < timestamps.length() - 1; i++) { + secondsElapsed[i] = timestamps.get(i + 1).getSecond() - timestamps.get(i).getSecond(); + } + assertEquals(secondsElapsed[0], 3); + assertEquals(secondsElapsed[1], 3); + assertEquals(secondsElapsed[2], 1); + } + } + + @Test + void longTimerNonblocking() throws TimeoutException { + final String orchestratorName = "ActivityAnyOf"; + final String externalEventActivityName = "externalEvent"; + final String externalEventWinner = "The external event completed first"; + final String timerEventWinner = "The timer event completed first"; + final Duration timerDuration = Duration.ofSeconds(20); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + Task externalEvent = ctx.waitForExternalEvent(externalEventActivityName, String.class); + Task longTimer = ctx.createTimer(timerDuration); + Task winnerEvent = ctx.anyOf(externalEvent, longTimer).await(); + if (winnerEvent == externalEvent) { + ctx.complete(externalEventWinner); + } else { + ctx.complete(timerEventWinner); + } + }).setMaximumTimerInterval(Duration.ofSeconds(3)).buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + client.raiseEvent(instanceId, externalEventActivityName, "Hello world"); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + String output = instance.readOutputAs(String.class); + assertNotNull(output); + assertTrue(output.equals(externalEventWinner)); + + long createdTime = instance.getCreatedAt().getEpochSecond(); + long completedTime = instance.getLastUpdatedAt().getEpochSecond(); + // Timer did not block execution + assertTrue(completedTime - createdTime < 5); + } + } + + @Test + void longTimerNonblockingNoExternal() throws TimeoutException { + final String orchestratorName = "ActivityAnyOf"; + final String externalEventActivityName = "externalEvent"; + final String externalEventWinner = "The external event completed first"; + final String timerEventWinner = "The timer event completed first"; + final Duration timerDuration = Duration.ofSeconds(20); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + Task externalEvent = ctx.waitForExternalEvent(externalEventActivityName, String.class); + Task longTimer = ctx.createTimer(timerDuration); + Task winnerEvent = ctx.anyOf(externalEvent, longTimer).await(); + if (winnerEvent == externalEvent) { + ctx.complete(externalEventWinner); + } else { + ctx.complete(timerEventWinner); + } + }).setMaximumTimerInterval(Duration.ofSeconds(3)).buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + String output = instance.readOutputAs(String.class); + assertNotNull(output); + assertTrue(output.equals(timerEventWinner)); + + long expectedCompletionSecond = instance.getCreatedAt().plus(timerDuration).getEpochSecond(); + long actualCompletionSecond = instance.getLastUpdatedAt().getEpochSecond(); + assertTrue(expectedCompletionSecond <= actualCompletionSecond); + } + } + + + @Test + void longTimeStampTimer() throws TimeoutException { + final String orchestratorName = "LongTimeStampTimer"; + final Duration delay = Duration.ofSeconds(7); + final ZonedDateTime zonedDateTime = ZonedDateTime.of(LocalDateTime.now().plusSeconds(delay.getSeconds()), ZoneId.systemDefault()); + + AtomicInteger counter = new AtomicInteger(); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + counter.incrementAndGet(); + ctx.createTimer(zonedDateTime).await(); + }) + .setMaximumTimerInterval(Duration.ofSeconds(3)) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + Duration timeout = delay.plus(defaultTimeout); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, timeout, false); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + // Verify that the delay actually happened + long expectedCompletionSecond = zonedDateTime.toInstant().getEpochSecond(); + long actualCompletionSecond = instance.getLastUpdatedAt().getEpochSecond(); + assertTrue(expectedCompletionSecond <= actualCompletionSecond); + + // Verify that the correct number of timers were created + // This should yield 4 (first invocation + replay invocations for internal timers 3s + 3s + 2s) + // The timer can be created at 7s or 8s as clock is not precise, so we need to allow for that + assertTrue(counter.get() >= 4 && counter.get() <= 5); + } + } + + @Test + void singleTimeStampTimer() throws IOException, TimeoutException { + final String orchestratorName = "SingleTimeStampTimer"; + final Duration delay = Duration.ofSeconds(3); + final ZonedDateTime zonedDateTime = ZonedDateTime.of(LocalDateTime.now().plusSeconds(delay.getSeconds()), ZoneId.systemDefault()); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> ctx.createTimer(zonedDateTime).await()) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + Duration timeout = delay.plus(defaultTimeout); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, timeout, false); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + // Verify that the delay actually happened + long expectedCompletionSecond = zonedDateTime.toInstant().getEpochSecond(); + long actualCompletionSecond = instance.getLastUpdatedAt().getEpochSecond(); + assertTrue(expectedCompletionSecond <= actualCompletionSecond); + } + } + + + @Test + void singleTimeStampCreateTimer() throws IOException, TimeoutException { + final String orchestratorName = "SingleTimeStampTimer"; + final Duration delay = Duration.ofSeconds(3); + final ZonedDateTime zonedDateTime = ZonedDateTime.of(LocalDateTime.now().plusSeconds(delay.getSeconds()), ZoneId.systemDefault()); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> ctx.createTimer(zonedDateTime).await()) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + Duration timeout = delay.plus(defaultTimeout); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, timeout, false); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + // Verify that the delay actually happened + long expectedCompletionSecond = zonedDateTime.toInstant().getEpochSecond(); + long actualCompletionSecond = instance.getLastUpdatedAt().getEpochSecond(); + assertTrue(expectedCompletionSecond <= actualCompletionSecond); + } + } + + @Test + void isReplaying() throws IOException, InterruptedException, TimeoutException { + final String orchestratorName = "SingleTimer"; + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + ArrayList list = new ArrayList(); + list.add(ctx.getIsReplaying()); + ctx.createTimer(Duration.ofSeconds(0)).await(); + list.add(ctx.getIsReplaying()); + ctx.createTimer(Duration.ofSeconds(0)).await(); + list.add(ctx.getIsReplaying()); + ctx.complete(list); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + OrchestrationMetadata instance = client.waitForInstanceCompletion( + instanceId, + defaultTimeout, + true); + + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + // Verify that the orchestrator reported the correct isReplaying values. + // Note that only the values of the *final* replay are returned. + List results = instance.readOutputAs(List.class); + assertEquals(3, results.size()); + assertTrue((Boolean) results.get(0)); + assertTrue((Boolean) results.get(1)); + assertFalse((Boolean) results.get(2)); + } + } + + @Test + void singleActivity() throws IOException, InterruptedException, TimeoutException { + final String orchestratorName = "SingleActivity"; + final String activityName = "Echo"; + final String input = Instant.now().toString(); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + String activityInput = ctx.getInput(String.class); + String output = ctx.callActivity(activityName, activityInput, String.class).await(); + ctx.complete(output); + }) + .addActivity(activityName, ctx -> { + return String.format("Hello, %s!", ctx.getInput(String.class)); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, input); + OrchestrationMetadata instance = client.waitForInstanceCompletion( + instanceId, + defaultTimeout, + true); + + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + String output = instance.readOutputAs(String.class); + String expected = String.format("Hello, %s!", input); + assertEquals(expected, output); + } + } + + @Test + void currentDateTimeUtc() throws IOException, TimeoutException { + final String orchestratorName = "CurrentDateTimeUtc"; + final String echoActivityName = "Echo"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + Instant currentInstant1 = ctx.getCurrentInstant(); + Instant originalInstant1 = ctx.callActivity(echoActivityName, currentInstant1, Instant.class).await(); + if (!currentInstant1.equals(originalInstant1)) { + ctx.complete(false); + return; + } + + Instant currentInstant2 = ctx.getCurrentInstant(); + Instant originalInstant2 = ctx.callActivity(echoActivityName, currentInstant2, Instant.class).await(); + if (!currentInstant2.equals(originalInstant2)) { + ctx.complete(false); + return; + } + + ctx.complete(!currentInstant1.equals(currentInstant2)); + }) + .addActivity(echoActivityName, ctx -> { + // Return the input back to the caller, regardless of its type + return ctx.getInput(Object.class); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + assertTrue(instance.readOutputAs(boolean.class)); + } + } + + @Test + void activityChain() throws IOException, TimeoutException { + final String orchestratorName = "ActivityChain"; + final String plusOneActivityName = "PlusOne"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + int value = ctx.getInput(int.class); + for (int i = 0; i < 10; i++) { + value = ctx.callActivity(plusOneActivityName, i, int.class).await(); + } + + ctx.complete(value); + }) + .addActivity(plusOneActivityName, ctx -> ctx.getInput(int.class) + 1) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + assertEquals(10, instance.readOutputAs(int.class)); + } + } + + @Test + void subOrchestration() throws TimeoutException { + final String orchestratorName = "SubOrchestration"; + DurableTaskGrpcWorker worker = this.createWorkerBuilder().addOrchestrator(orchestratorName, ctx -> { + int result = 5; + int input = ctx.getInput(int.class); + if (input < 3) { + result += ctx.callSubOrchestrator(orchestratorName, input + 1, int.class).await(); + } + ctx.complete(result); + }).buildAndStart(); + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 1); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + assertEquals(15, instance.readOutputAs(int.class)); + } + } + + @Test + void continueAsNew() throws TimeoutException { + final String orchestratorName = "continueAsNew"; + final Duration delay = Duration.ofSeconds(0); + DurableTaskGrpcWorker worker = this.createWorkerBuilder().addOrchestrator(orchestratorName, ctx -> { + int input = ctx.getInput(int.class); + if (input < 10) { + ctx.createTimer(delay).await(); + ctx.continueAsNew(input + 1); + } else { + ctx.complete(input); + } + }).buildAndStart(); + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 1); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + assertEquals(10, instance.readOutputAs(int.class)); + } + } + + @Test + void continueAsNewWithExternalEvents() throws TimeoutException, InterruptedException { + final String orchestratorName = "continueAsNewWithExternalEvents"; + final String eventName = "MyEvent"; + final int expectedEventCount = 10; + final Duration delay = Duration.ofSeconds(0); + DurableTaskGrpcWorker worker = this.createWorkerBuilder().addOrchestrator(orchestratorName, ctx -> { + int receivedEventCount = ctx.getInput(int.class); + + if (receivedEventCount < expectedEventCount) { + ctx.waitForExternalEvent(eventName, int.class).await(); + ctx.continueAsNew(receivedEventCount + 1, true); + } else { + ctx.complete(receivedEventCount); + } + }).buildAndStart(); + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + + for (int i = 0; i < expectedEventCount; i++) { + client.raiseEvent(instanceId, eventName, i); + } + + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + assertEquals(expectedEventCount, instance.readOutputAs(int.class)); + } + } + + @Test + void termination() throws TimeoutException { + final String orchestratorName = "Termination"; + final Duration delay = Duration.ofSeconds(3); + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> ctx.createTimer(delay).await()) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + String expectOutput = "I'll be back."; + client.terminate(instanceId, expectOutput); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(instanceId, instance.getInstanceId()); + assertEquals(OrchestrationRuntimeStatus.TERMINATED, instance.getRuntimeStatus()); + assertEquals(expectOutput, instance.readOutputAs(String.class)); + } + } + + + @ParameterizedTest + @ValueSource(booleans = {true}) + void restartOrchestrationWithNewInstanceId(boolean restartWithNewInstanceId) throws TimeoutException { + final String orchestratorName = "restart"; + final Duration delay = Duration.ofSeconds(3); + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> ctx.createTimer(delay).await()) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, "RestartTest"); + client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + String newInstanceId = client.restartInstance(instanceId, restartWithNewInstanceId); + OrchestrationMetadata instance = client.waitForInstanceCompletion(newInstanceId, defaultTimeout, true); + + if (restartWithNewInstanceId) { + assertNotEquals(instanceId, newInstanceId); + } else { + assertEquals(instanceId, newInstanceId); + } + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + assertEquals("\"RestartTest\"", instance.getSerializedInput()); + } + } + + @Test + void restartOrchestrationThrowsException() { + final String orchestratorName = "restart"; + final Duration delay = Duration.ofSeconds(3); + final String nonExistentId = "123"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> ctx.createTimer(delay).await()) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + client.scheduleNewOrchestrationInstance(orchestratorName, "RestartTest"); + + assertThrows( + IllegalArgumentException.class, + () -> client.restartInstance(nonExistentId, true) + ); + } + + } + + @Test + @Disabled("Test is disabled for investigation, fixing the test retry pattern exposed the failure") + void suspendResumeOrchestration() throws TimeoutException, InterruptedException { + final String orchestratorName = "suspend"; + final String eventName = "MyEvent"; + final String eventPayload = "testPayload"; + final Duration suspendTimeout = Duration.ofSeconds(5); + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + String payload = ctx.waitForExternalEvent(eventName, String.class).await(); + ctx.complete(payload); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + client.suspendInstance(instanceId); + OrchestrationMetadata instance = client.waitForInstanceStart(instanceId, defaultTimeout); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.SUSPENDED, instance.getRuntimeStatus()); + + client.raiseEvent(instanceId, eventName, eventPayload); + + assertThrows( + TimeoutException.class, + () -> client.waitForInstanceCompletion(instanceId, suspendTimeout, false), + "Expected to throw TimeoutException, but it didn't" + ); + + String resumeReason = "Resume for testing."; + client.resumeInstance(instanceId, resumeReason); + instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(instanceId, instance.getInstanceId()); + assertEquals(eventPayload, instance.readOutputAs(String.class)); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + } + } + + @Test + @Disabled("Test is disabled for investigation)") + void terminateSuspendOrchestration() throws TimeoutException, InterruptedException { + final String orchestratorName = "suspendResume"; + final String eventName = "MyEvent"; + final String eventPayload = "testPayload"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + String payload = ctx.waitForExternalEvent(eventName, String.class).await(); + ctx.complete(payload); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + String suspendReason = "Suspend for testing."; + client.suspendInstance(instanceId, suspendReason); + client.terminate(instanceId, null); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, false); + assertNotNull(instance); + assertEquals(instanceId, instance.getInstanceId()); + assertEquals(OrchestrationRuntimeStatus.TERMINATED, instance.getRuntimeStatus()); + } + } + + @Test + void activityFanOut() throws IOException, TimeoutException { + final String orchestratorName = "ActivityFanOut"; + final String activityName = "ToString"; + final int activityCount = 10; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + // Schedule each task to run in parallel + List> parallelTasks = IntStream.range(0, activityCount) + .mapToObj(i -> ctx.callActivity(activityName, i, String.class)) + .collect(Collectors.toList()); + + // Wait for all tasks to complete, then sort and reverse the results + List results = ctx.allOf(parallelTasks).await(); + Collections.sort(results); + Collections.reverse(results); + ctx.complete(results); + }) + .addActivity(activityName, ctx -> ctx.getInput(Object.class).toString()) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + List output = instance.readOutputAs(List.class); + assertNotNull(output); + assertEquals(activityCount, output.size()); + assertEquals(String.class, output.get(0).getClass()); + + // Expected: ["9", "8", "7", "6", "5", "4", "3", "2", "1", "0"] + for (int i = 0; i < activityCount; i++) { + String expected = String.valueOf(activityCount - i - 1); + assertEquals(expected, output.get(i).toString()); + } + } + } + + @Test + void externalEvents() throws IOException, TimeoutException { + final String orchestratorName = "ExternalEvents"; + final String eventName = "MyEvent"; + final int eventCount = 10; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + int i; + for (i = 0; i < eventCount; i++) { + // block until the event is received + int payload = ctx.waitForExternalEvent(eventName, int.class).await(); + if (payload != i) { + ctx.complete(-1); + return; + } + } + + ctx.complete(i); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + + for (int i = 0; i < eventCount; i++) { + client.raiseEvent(instanceId, eventName, i); + } + + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + int output = instance.readOutputAs(int.class); + assertEquals(eventCount, output); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void externalEventsWithTimeouts(boolean raiseEvent) throws IOException, TimeoutException { + final String orchestratorName = "ExternalEventsWithTimeouts"; + final String eventName = "MyEvent"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + try { + ctx.waitForExternalEvent(eventName, Duration.ofSeconds(3)).await(); + ctx.complete("received"); + } catch (TaskCanceledException e) { + ctx.complete(e.getMessage()); + } + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + + client.waitForInstanceStart(instanceId, defaultTimeout); + if (raiseEvent) { + client.raiseEvent(instanceId, eventName); + } + + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + String output = instance.readOutputAs(String.class); + if (raiseEvent) { + assertEquals("received", output); + } else { + assertEquals("Timeout of PT3S expired while waiting for an event named '" + eventName + "' (ID = 0).", output); + } + } + } + + @Test + void setCustomStatus() throws TimeoutException { + final String orchestratorName = "SetCustomStatus"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + ctx.setCustomStatus("Started!"); + Object customStatus = ctx.waitForExternalEvent("StatusEvent", Object.class).await(); + ctx.setCustomStatus(customStatus); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + + OrchestrationMetadata metadata = client.waitForInstanceStart(instanceId, defaultTimeout, true); + assertNotNull(metadata); + assertEquals("Started!", metadata.readCustomStatusAs(String.class)); + + Map payload = new HashMap() {{ + put("Hello", 45); + }}; + client.raiseEvent(metadata.getInstanceId(), "StatusEvent", payload); + + metadata = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(metadata); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus()); + assertTrue(metadata.isCustomStatusFetched()); + assertEquals(payload, metadata.readCustomStatusAs(HashMap.class)); + } + } + + @Test + void clearCustomStatus() throws TimeoutException { + final String orchestratorName = "ClearCustomStatus"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + ctx.setCustomStatus("Started!"); + ctx.waitForExternalEvent("StatusEvent").await(); + ctx.clearCustomStatus(); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + + OrchestrationMetadata metadata = client.waitForInstanceStart(instanceId, defaultTimeout, true); + assertNotNull(metadata); + assertEquals("Started!", metadata.readCustomStatusAs(String.class)); + + client.raiseEvent(metadata.getInstanceId(), "StatusEvent"); + + metadata = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(metadata); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus()); + assertFalse(metadata.isCustomStatusFetched()); + } + } + + // due to clock drift, client/worker and sidecar time are not exactly synchronized, this test needs to accommodate for client vs backend timestamps difference + @Test + @Disabled("Test is disabled for investigation, fixing the test retry pattern exposed the failure") + void multiInstanceQuery() throws TimeoutException { + final String plusOne = "plusOne"; + final String waitForEvent = "waitForEvent"; + final DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(plusOne, ctx -> { + int value = ctx.getInput(int.class); + for (int i = 0; i < 10; i++) { + value = ctx.callActivity(plusOne, value, int.class).await(); + } + ctx.complete(value); + }) + .addActivity(plusOne, ctx -> ctx.getInput(int.class) + 1) + .addOrchestrator(waitForEvent, ctx -> { + String name = ctx.getInput(String.class); + String output = ctx.waitForExternalEvent(name, String.class).await(); + ctx.complete(output); + }).buildAndStart(); + + try (worker; client) { + Instant startTime = Instant.now(); + String prefix = startTime.toString(); + + IntStream.range(0, 5).mapToObj(i -> { + String instanceId = String.format("%s.sequence.%d", prefix, i); + client.scheduleNewOrchestrationInstance(plusOne, 0, instanceId); + return instanceId; + }).collect(Collectors.toUnmodifiableList()).forEach(id -> { + try { + client.waitForInstanceCompletion(id, defaultTimeout, true); + } catch (TimeoutException e) { + e.printStackTrace(); + } + }); + + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + } + + Instant sequencesFinishedTime = Instant.now(); + + IntStream.range(0, 5).mapToObj(i -> { + String instanceId = String.format("%s.waiter.%d", prefix, i); + client.scheduleNewOrchestrationInstance(waitForEvent, String.valueOf(i), instanceId); + return instanceId; + }).collect(Collectors.toUnmodifiableList()).forEach(id -> { + try { + client.waitForInstanceStart(id, defaultTimeout); + } catch (TimeoutException e) { + e.printStackTrace(); + } + }); + + // Create one query object and reuse it for multiple queries + OrchestrationStatusQuery query = new OrchestrationStatusQuery(); + OrchestrationStatusQueryResult result = null; + + // Return all instances + result = client.queryInstances(query); + assertEquals(10, result.getOrchestrationState().size()); + + // Test CreatedTimeTo filter + query.setCreatedTimeTo(startTime.minus(Duration.ofSeconds(1))); + result = client.queryInstances(query); + assertTrue(result.getOrchestrationState().isEmpty(), + "Result should be empty but found " + result.getOrchestrationState().size() + " instances: " + + "Start time: " + startTime + ", " + + result.getOrchestrationState().stream() + .map(state -> String.format("\nID: %s, Status: %s, Created: %s", + state.getInstanceId(), + state.getRuntimeStatus(), + state.getCreatedAt())) + .collect(Collectors.joining(", "))); + + query.setCreatedTimeTo(sequencesFinishedTime); + result = client.queryInstances(query); + // Verify all returned instances contain "sequence" in their IDs + assertEquals(5, result.getOrchestrationState().stream() + .filter(state -> state.getInstanceId().contains("sequence")) + .count(), + "Expected exactly 5 instances with 'sequence' in their IDs"); + + query.setCreatedTimeTo(Instant.now().plus(Duration.ofSeconds(1))); + result = client.queryInstances(query); + assertEquals(10, result.getOrchestrationState().size()); + + // Test CreatedTimeFrom filter + query.setCreatedTimeFrom(Instant.now().plus(Duration.ofSeconds(1))); + result = client.queryInstances(query); + assertTrue(result.getOrchestrationState().isEmpty()); + + query.setCreatedTimeFrom(sequencesFinishedTime.minus(Duration.ofSeconds(5))); + result = client.queryInstances(query); + assertEquals(5, result.getOrchestrationState().stream() + .filter(state -> state.getInstanceId().contains("sequence")) + .count(), + "Expected exactly 5 instances with 'sequence' in their IDs"); + + query.setCreatedTimeFrom(startTime.minus(Duration.ofSeconds(1))); + result = client.queryInstances(query); + assertEquals(10, result.getOrchestrationState().size()); + + // Test RuntimeStatus filter + HashSet statusFilters = Stream.of( + OrchestrationRuntimeStatus.PENDING, + OrchestrationRuntimeStatus.FAILED, + OrchestrationRuntimeStatus.TERMINATED + ).collect(Collectors.toCollection(HashSet::new)); + + query.setRuntimeStatusList(new ArrayList<>(statusFilters)); + result = client.queryInstances(query); + assertTrue(result.getOrchestrationState().isEmpty()); + + statusFilters.add(OrchestrationRuntimeStatus.RUNNING); + query.setRuntimeStatusList(new ArrayList<>(statusFilters)); + result = client.queryInstances(query); + assertEquals(5, result.getOrchestrationState().size()); + + statusFilters.add(OrchestrationRuntimeStatus.COMPLETED); + query.setRuntimeStatusList(new ArrayList<>(statusFilters)); + result = client.queryInstances(query); + assertEquals(10, result.getOrchestrationState().size()); + + statusFilters.remove(OrchestrationRuntimeStatus.RUNNING); + query.setRuntimeStatusList(new ArrayList<>(statusFilters)); + result = client.queryInstances(query); + assertEquals(5, result.getOrchestrationState().size()); + + statusFilters.clear(); + query.setRuntimeStatusList(new ArrayList<>(statusFilters)); + result = client.queryInstances(query); + assertEquals(10, result.getOrchestrationState().size()); + + // Test InstanceIdPrefix + query.setInstanceIdPrefix("Foo"); + result = client.queryInstances(query); + assertTrue(result.getOrchestrationState().isEmpty()); + + query.setInstanceIdPrefix(prefix); + result = client.queryInstances(query); + assertEquals(10, result.getOrchestrationState().size()); + + // Test PageSize and ContinuationToken + HashSet instanceIds = new HashSet<>(); + query.setMaxInstanceCount(0); + while (query.getMaxInstanceCount() < 10) { + query.setMaxInstanceCount(query.getMaxInstanceCount() + 1); + result = client.queryInstances(query); + int total = result.getOrchestrationState().size(); + assertEquals(query.getMaxInstanceCount(), total); + result.getOrchestrationState().forEach(state -> assertTrue(instanceIds.add(state.getInstanceId()))); + while (total < 10) { + query.setContinuationToken(result.getContinuationToken()); + result = client.queryInstances(query); + int count = result.getOrchestrationState().size(); + assertNotEquals(0, count); + assertTrue(count <= query.getMaxInstanceCount()); + total += count; + assertTrue(total <= 10); + result.getOrchestrationState().forEach(state -> assertTrue(instanceIds.add(state.getInstanceId()))); + } + query.setContinuationToken(null); + instanceIds.clear(); + } + + // Test ShowInput + query.setFetchInputsAndOutputs(true); + query.setCreatedTimeFrom(sequencesFinishedTime); + result = client.queryInstances(query); + result.getOrchestrationState().forEach(state -> assertNotNull(state.readInputAs(String.class))); + + query.setFetchInputsAndOutputs(false); + query.setCreatedTimeFrom(sequencesFinishedTime); + result = client.queryInstances(query); + result.getOrchestrationState().forEach(state -> assertThrows(IllegalStateException.class, () -> state.readInputAs(String.class))); + } + } + + @Test + void purgeInstanceId() throws TimeoutException { + final String orchestratorName = "PurgeInstance"; + final String plusOneActivityName = "PlusOne"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + int value = ctx.getInput(int.class); + value = ctx.callActivity(plusOneActivityName, value, int.class).await(); + ctx.complete(value); + }) + .addActivity(plusOneActivityName, ctx -> ctx.getInput(int.class) + 1) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + OrchestrationMetadata metadata = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(metadata); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus()); + assertEquals(1, metadata.readOutputAs(int.class)); + + PurgeResult result = client.purgeInstance(instanceId); + assertEquals(1, result.getDeletedInstanceCount()); + + metadata = client.getInstanceMetadata(instanceId, true); + assertFalse(metadata.isInstanceFound()); + } + } + + @Test + @Disabled("Test is disabled as is not supported by the sidecar") + void purgeInstanceFilter() throws TimeoutException { + final String orchestratorName = "PurgeInstance"; + final String plusOne = "PlusOne"; + final String plusTwo = "PlusTwo"; + final String terminate = "Termination"; + + final Duration delay = Duration.ofSeconds(1); + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + int value = ctx.getInput(int.class); + value = ctx.callActivity(plusOne, value, int.class).await(); + ctx.complete(value); + }) + .addActivity(plusOne, ctx -> ctx.getInput(int.class) + 1) + .addOrchestrator(plusOne, ctx -> { + int value = ctx.getInput(int.class); + value = ctx.callActivity(plusOne, value, int.class).await(); + ctx.complete(value); + }) + .addOrchestrator(plusTwo, ctx -> { + int value = ctx.getInput(int.class); + value = ctx.callActivity(plusTwo, value, int.class).await(); + ctx.complete(value); + }) + .addActivity(plusTwo, ctx -> ctx.getInput(int.class) + 2) + .addOrchestrator(terminate, ctx -> ctx.createTimer(delay).await()) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + Instant startTime = Instant.now(); + + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + OrchestrationMetadata metadata = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(metadata); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus()); + assertEquals(1, metadata.readOutputAs(int.class)); + + // Test CreatedTimeFrom + PurgeInstanceCriteria criteria = new PurgeInstanceCriteria(); + criteria.setCreatedTimeFrom(startTime.minus(Duration.ofSeconds(1))); + + PurgeResult result = client.purgeInstances(criteria); + assertEquals(1, result.getDeletedInstanceCount()); + metadata = client.getInstanceMetadata(instanceId, true); + assertFalse(metadata.isInstanceFound()); + + // Test CreatedTimeTo + criteria.setCreatedTimeTo(Instant.now()); + + result = client.purgeInstances(criteria); + assertEquals(0, result.getDeletedInstanceCount()); + metadata = client.getInstanceMetadata(instanceId, true); + assertFalse(metadata.isInstanceFound()); + + // Test CreatedTimeFrom, CreatedTimeTo, and RuntimeStatus + String instanceId1 = client.scheduleNewOrchestrationInstance(plusOne, 0); + metadata = client.waitForInstanceCompletion(instanceId1, defaultTimeout, true); + assertNotNull(metadata); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus()); + assertEquals(1, metadata.readOutputAs(int.class)); + + String instanceId2 = client.scheduleNewOrchestrationInstance(plusTwo, 10); + metadata = client.waitForInstanceCompletion(instanceId2, defaultTimeout, true); + assertNotNull(metadata); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus()); + assertEquals(12, metadata.readOutputAs(int.class)); + + String instanceId3 = client.scheduleNewOrchestrationInstance(terminate); + client.terminate(instanceId3, terminate); + metadata = client.waitForInstanceCompletion(instanceId3, defaultTimeout, true); + assertNotNull(metadata); + assertEquals(OrchestrationRuntimeStatus.TERMINATED, metadata.getRuntimeStatus()); + assertEquals(terminate, metadata.readOutputAs(String.class)); + + HashSet runtimeStatusFilters = Stream.of( + OrchestrationRuntimeStatus.TERMINATED, + OrchestrationRuntimeStatus.COMPLETED + ).collect(Collectors.toCollection(HashSet::new)); + + criteria.setCreatedTimeTo(Instant.now()); + criteria.setRuntimeStatusList(new ArrayList<>(runtimeStatusFilters)); + result = client.purgeInstances(criteria); + + assertEquals(3, result.getDeletedInstanceCount()); + metadata = client.getInstanceMetadata(instanceId1, true); + assertFalse(metadata.isInstanceFound()); + metadata = client.getInstanceMetadata(instanceId2, true); + assertFalse(metadata.isInstanceFound()); + metadata = client.getInstanceMetadata(instanceId3, true); + assertFalse(metadata.isInstanceFound()); + } + } + + @Test + void purgeInstanceFilterTimeout() throws TimeoutException { + final String orchestratorName = "PurgeInstance"; + final String plusOne = "PlusOne"; + final String plusTwo = "PlusTwo"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + int value = ctx.getInput(int.class); + value = ctx.callActivity(plusOne, value, int.class).await(); + ctx.complete(value); + }) + .addActivity(plusOne, ctx -> ctx.getInput(int.class) + 1) + .addOrchestrator(plusOne, ctx -> { + int value = ctx.getInput(int.class); + value = ctx.callActivity(plusOne, value, int.class).await(); + ctx.complete(value); + }) + .addOrchestrator(plusTwo, ctx -> { + int value = ctx.getInput(int.class); + value = ctx.callActivity(plusTwo, value, int.class).await(); + ctx.complete(value); + }) + .addActivity(plusTwo, ctx -> ctx.getInput(int.class) + 2) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + Instant startTime = Instant.now(); + + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + OrchestrationMetadata metadata = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(metadata); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus()); + assertEquals(1, metadata.readOutputAs(int.class)); + + String instanceId1 = client.scheduleNewOrchestrationInstance(plusOne, 0); + metadata = client.waitForInstanceCompletion(instanceId1, defaultTimeout, true); + assertNotNull(metadata); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus()); + assertEquals(1, metadata.readOutputAs(int.class)); + + String instanceId2 = client.scheduleNewOrchestrationInstance(plusTwo, 10); + metadata = client.waitForInstanceCompletion(instanceId2, defaultTimeout, true); + assertNotNull(metadata); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus()); + assertEquals(12, metadata.readOutputAs(int.class)); + + PurgeInstanceCriteria criteria = new PurgeInstanceCriteria(); + criteria.setCreatedTimeFrom(startTime); + criteria.setTimeout(Duration.ofNanos(1)); + + assertThrows(TimeoutException.class, () -> client.purgeInstances(criteria)); + } + } + + @Test + void waitForInstanceStartThrowsException() { + final String orchestratorName = "orchestratorName"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + try { + // The orchestration remains in the "Pending" state until the first await statement + TimeUnit.SECONDS.sleep(5); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + var instanceId = UUID.randomUUID().toString(); + Thread thread = new Thread(() -> { + client.scheduleNewOrchestrationInstance(orchestratorName, null, instanceId); + }); + thread.start(); + + assertThrows(TimeoutException.class, () -> client.waitForInstanceStart(instanceId, Duration.ofSeconds(2))); + } + } + + @Test + void waitForInstanceCompletionThrowsException() { + final String orchestratorName = "orchestratorName"; + final String plusOneActivityName = "PlusOne"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + int value = ctx.getInput(int.class); + value = ctx.callActivity(plusOneActivityName, value, int.class).await(); + ctx.complete(value); + }) + .addActivity(plusOneActivityName, ctx -> { + try { + // The orchestration is started but not completed within the orchestration completion timeout due the below activity delay + TimeUnit.SECONDS.sleep(5); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return ctx.getInput(int.class) + 1; + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + assertThrows(TimeoutException.class, () -> client.waitForInstanceCompletion(instanceId, Duration.ofSeconds(2), false)); + } + } + + @Test + void activityFanOutWithException() throws TimeoutException { + final String orchestratorName = "ActivityFanOut"; + final String activityName = "Divide"; + final int count = 10; + final String exceptionMessage = "2 out of 6 tasks failed with an exception. See the exceptions list for details."; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + // Schedule each task to run in parallel + List> parallelTasks = IntStream.of(1, 2, 0, 4, 0, 6) + .mapToObj(i -> ctx.callActivity(activityName, i, Integer.class)) + .collect(Collectors.toList()); + + // Wait for all tasks to complete + try { + List results = ctx.allOf(parallelTasks).await(); + ctx.complete(results); + } catch (CompositeTaskFailedException e) { + assertNotNull(e); + assertEquals(2, e.getExceptions().size()); + assertEquals(TaskFailedException.class, e.getExceptions().get(0).getClass()); + assertEquals(TaskFailedException.class, e.getExceptions().get(1).getClass()); + // taskId in the exception below is based on parallelTasks input + assertEquals(getExceptionMessage(activityName, 2, "/ by zero"), e.getExceptions().get(0).getMessage()); + assertEquals(getExceptionMessage(activityName, 4, "/ by zero"), e.getExceptions().get(1).getMessage()); + throw e; + } + }) + .addActivity(activityName, ctx -> count / ctx.getInput(Integer.class)) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus()); + + List output = instance.readOutputAs(List.class); + assertNull(output); + + FailureDetails details = instance.getFailureDetails(); + assertNotNull(details); + assertEquals(exceptionMessage, details.getErrorMessage()); + assertEquals("io.dapr.durabletask.CompositeTaskFailedException", details.getErrorType()); + assertNotNull(details.getStackTrace()); + } + } + + private static String getExceptionMessage(String taskName, int expectedTaskId, String expectedExceptionMessage) { + return String.format( + "Task '%s' (#%d) failed with an unhandled exception: %s", + taskName, + expectedTaskId, + expectedExceptionMessage); + } + + @Test + void thenApply() throws IOException, InterruptedException, TimeoutException { + final String orchestratorName = "thenApplyActivity"; + final String activityName = "Echo"; + final String suffix = "-test"; + final String input = Instant.now().toString(); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + String activityInput = ctx.getInput(String.class); + String output = ctx.callActivity(activityName, activityInput, String.class).thenApply(s -> s + suffix).await(); + ctx.complete(output); + }) + .addActivity(activityName, ctx -> { + return String.format("Hello, %s!", ctx.getInput(String.class)); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, input); + OrchestrationMetadata instance = client.waitForInstanceCompletion( + instanceId, + defaultTimeout, + true); + + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + String output = instance.readOutputAs(String.class); + String expected = String.format("Hello, %s!%s", input, suffix); + assertEquals(expected, output); + } + } + + @Test + void externalEventThenAccept() throws InterruptedException, TimeoutException { + final String orchestratorName = "continueAsNewWithExternalEvents"; + final String eventName = "MyEvent"; + final int expectedEventCount = 10; + DurableTaskGrpcWorker worker = this.createWorkerBuilder().addOrchestrator(orchestratorName, ctx -> { + int receivedEventCount = ctx.getInput(int.class); + + if (receivedEventCount < expectedEventCount) { + ctx.waitForExternalEvent(eventName, int.class) + .thenAccept(s -> { + ctx.continueAsNew(receivedEventCount + 1); + return; + }) + .await(); + } else { + ctx.complete(receivedEventCount); + } + }).buildAndStart(); + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + + for (int i = 0; i < expectedEventCount; i++) { + client.raiseEvent(instanceId, eventName, i); + } + + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + assertEquals(expectedEventCount, instance.readOutputAs(int.class)); + } + } + + @Test + void activityAllOf() throws IOException, TimeoutException { + final String orchestratorName = "ActivityAllOf"; + final String activityName = "ToString"; + final String retryActivityName = "RetryToString"; + final int activityMiddle = 5; + final int activityCount = 10; + final AtomicBoolean throwException = new AtomicBoolean(true); + final RetryPolicy retryPolicy = new RetryPolicy(2, Duration.ofSeconds(5)); + final TaskOptions taskOptions = TaskOptions.withRetryPolicy(retryPolicy); + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + List> parallelTasks = IntStream.range(0, activityMiddle * 2) + .mapToObj(i -> { + if (i < activityMiddle) { + return ctx.callActivity(activityName, i, String.class); + } else { + return ctx.callActivity(retryActivityName, i, taskOptions, String.class); + } + }) + .collect(Collectors.toList()); + + // Wait for all tasks to complete, then sort and reverse the results + List results = ctx.allOf(parallelTasks).await(); + Collections.sort(results); + Collections.reverse(results); + ctx.complete(results); + }) + .addActivity(activityName, ctx -> ctx.getInput(Object.class).toString()) + .addActivity(retryActivityName, ctx -> { + if (throwException.get()) { + throwException.compareAndSet(true, false); + throw new RuntimeException("test retry"); + } + return ctx.getInput(Object.class).toString(); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + List output = instance.readOutputAs(List.class); + assertNotNull(output); + assertEquals(activityCount, output.size()); + assertEquals(String.class, output.get(0).getClass()); + + // Expected: ["9", "8", "7", "6", "5", "4", "3", "2", "1", "0"] + for (int i = 0; i < activityCount; i++) { + String expected = String.valueOf(activityCount - i - 1); + assertEquals(expected, output.get(i).toString()); + } + } + } + + @Test + void activityAllOfException() throws IOException, TimeoutException { + final String orchestratorName = "ActivityAllOf"; + final String activityName = "ToString"; + final String retryActivityName = "RetryToStringException"; + final String result = "test fail"; + final int activityMiddle = 5; + final RetryPolicy retryPolicy = new RetryPolicy(2, Duration.ofSeconds(5)); + final TaskOptions taskOptions = TaskOptions.withRetryPolicy(retryPolicy); + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + List> parallelTasks = IntStream.range(0, activityMiddle * 2) + .mapToObj(i -> { + if (i < activityMiddle) { + return ctx.callActivity(activityName, i, String.class); + } else { + return ctx.callActivity(retryActivityName, i, taskOptions, String.class); + } + }) + .collect(Collectors.toList()); + + // Wait for all tasks to complete, then sort and reverse the results + try { + List results = null; + results = ctx.allOf(parallelTasks).await(); + Collections.sort(results); + Collections.reverse(results); + ctx.complete(results); + } catch (CompositeTaskFailedException e) { + // only catch this type of exception to ensure the expected type of exception is thrown out. + for (Exception exception : e.getExceptions()) { + if (exception instanceof TaskFailedException) { + TaskFailedException taskFailedException = (TaskFailedException) exception; + System.out.println("Task: " + taskFailedException.getTaskName() + + " Failed for cause: " + taskFailedException.getErrorDetails().getErrorMessage()); + } + } + } + ctx.complete(result); + }) + .addActivity(activityName, ctx -> ctx.getInput(Object.class).toString()) + .addActivity(retryActivityName, ctx -> { + // only throw exception + throw new RuntimeException("test retry"); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + String output = instance.readOutputAs(String.class); + assertNotNull(output); + assertEquals(String.class, output.getClass()); + assertEquals(result, output); + } + } + + @Test + void activityAnyOf() throws IOException, TimeoutException { + final String orchestratorName = "ActivityAnyOf"; + final String activityName = "ToString"; + final String retryActivityName = "RetryToString"; + final int activityMiddle = 5; + final int activityCount = 10; + final AtomicBoolean throwException = new AtomicBoolean(true); + final RetryPolicy retryPolicy = new RetryPolicy(2, Duration.ofSeconds(5)); + final TaskOptions taskOptions = TaskOptions.withRetryPolicy(retryPolicy); + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + List> parallelTasks = IntStream.range(0, activityMiddle * 2) + .mapToObj(i -> { + if (i < activityMiddle) { + return ctx.callActivity(activityName, i, String.class); + } else { + return ctx.callActivity(retryActivityName, i, taskOptions, String.class); + } + }) + .collect(Collectors.toList()); + + String results = (String) ctx.anyOf(parallelTasks).await().await(); + ctx.complete(results); + }) + .addActivity(activityName, ctx -> ctx.getInput(Object.class).toString()) + .addActivity(retryActivityName, ctx -> { + if (throwException.get()) { + throwException.compareAndSet(true, false); + throw new RuntimeException("test retry"); + } + return ctx.getInput(Object.class).toString(); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + + String output = instance.readOutputAs(String.class); + assertNotNull(output); + assertTrue(Integer.parseInt(output) >= 0 && Integer.parseInt(output) < activityCount); + } + } + + @Test + public void newUUIDTest() { + String orchestratorName = "test-new-uuid"; + String echoActivityName = "Echo"; + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + // Test 1: Ensure two consequiteively created GUIDs are not unique + UUID currentUUID0 = ctx.newUuid(); + UUID currentUUID1 = ctx.newUuid(); + if (currentUUID0.equals(currentUUID1)) { + ctx.complete(false); + } + + // Test 2: Ensure that the same GUID values are created on each replay + UUID originalUUID1 = ctx.callActivity(echoActivityName, currentUUID1, UUID.class).await(); + if (!currentUUID1.equals(originalUUID1)) { + ctx.complete(false); + } + + // Test 3: Ensure that the same UUID values are created on each replay even after an await + UUID currentUUID2 = ctx.newUuid(); + UUID originalUUID2 = ctx.callActivity(echoActivityName, currentUUID2, UUID.class).await(); + if (!currentUUID2.equals(originalUUID2)) { + ctx.complete(false); + } + + // Test 4: Finish confirming that every generated UUID is unique + if (currentUUID1.equals(currentUUID2)) ctx.complete(false); + else ctx.complete(true); + }) + .addActivity(echoActivityName, ctx -> { + System.out.println("##### echoActivityName: " + ctx.getInput(UUID.class)); + return ctx.getInput(UUID.class); + }) + .buildAndStart(); + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + assertTrue(instance.readOutputAs(boolean.class)); + } catch (TimeoutException e) { + throw new RuntimeException(e); + } + } + + + @Test + public void taskExecutionIdTest() { + var orchestratorName = "test-task-execution-id"; + var retryActivityName = "RetryN"; + final RetryPolicy retryPolicy = new RetryPolicy(4, Duration.ofSeconds(3)); + final TaskOptions taskOptions = TaskOptions.withRetryPolicy(retryPolicy); + + var execMap = new HashMap(); + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + ctx.callActivity(retryActivityName, null, taskOptions).await(); + ctx.callActivity(retryActivityName, null, taskOptions).await(); + ctx.complete(true); + }) + .addActivity(retryActivityName, ctx -> { + System.out.println("##### RetryN[executionId]: " + ctx.getTaskExecutionId()); + var c = execMap.get(ctx.getTaskExecutionId()); + if (c == null) { + c = 0; + } else { + c++; + } + + execMap.put(ctx.getTaskExecutionId(), c); + if (c < 2) { + throw new RuntimeException("test retry"); + } + return null; + }) + .buildAndStart(); + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + assertEquals(2, execMap.size()); + assertTrue(instance.readOutputAs(boolean.class)); + } catch (TimeoutException e) { + throw new RuntimeException(e); + } + + } + +} + + diff --git a/durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskGrpcClientTlsTest.java b/durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskGrpcClientTlsTest.java new file mode 100644 index 000000000..b60b26be7 --- /dev/null +++ b/durabletask-client/src/test/java/io/dapr/durabletask/DurableTaskGrpcClientTlsTest.java @@ -0,0 +1,342 @@ +///* +// * Copyright 2025 The Dapr Authors +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * http://www.apache.org/licenses/LICENSE-2.0 +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +//limitations under the License. +//*/ +//package io.dapr.durabletask; +// +//import org.junit.jupiter.api.AfterEach; +//import org.junit.jupiter.api.Test; +//import org.junit.jupiter.api.io.TempDir; +//import org.junit.jupiter.api.condition.EnabledOnOs; +//import org.junit.jupiter.api.condition.OS; +//import org.junit.jupiter.api.Assumptions; +// +//import java.io.File; +//import java.nio.file.Files; +//import java.nio.file.Path; +//import java.security.KeyPair; +//import java.security.KeyPairGenerator; +//import java.security.cert.X509Certificate; +//import java.util.Base64; +//import java.util.Date; +//import java.math.BigInteger; +// +//import org.bouncycastle.asn1.x500.X500Name; +//import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +//import org.bouncycastle.cert.X509v3CertificateBuilder; +//import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +//import org.bouncycastle.operator.ContentSigner; +//import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +// +//import static org.junit.jupiter.api.Assertions.*; +// +//public class DurableTaskGrpcClientTlsTest { +// private static final int DEFAULT_PORT = 4001; +// private static final String DEFAULT_SIDECAR_IP = "127.0.0.1"; +// +// @TempDir +// Path tempDir; +// +// // Track the client for cleanup +// private DurableTaskGrpcClient client; +// +// @AfterEach +// void tearDown() throws Exception { +// if (client != null) { +// client.close(); +// client = null; +// } +// } +// +// // Helper method to generate a key pair for testing +// private static KeyPair generateKeyPair() throws Exception { +// KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); +// keyPairGenerator.initialize(2048); +// return keyPairGenerator.generateKeyPair(); +// } +// +// // Helper method to generate a self-signed certificate +// private static X509Certificate generateCertificate(KeyPair keyPair) throws Exception { +// X500Name issuer = new X500Name("CN=Test Certificate"); +// X500Name subject = new X500Name("CN=Test Certificate"); +// Date notBefore = new Date(System.currentTimeMillis() - 24 * 60 * 60 * 1000); +// Date notAfter = new Date(System.currentTimeMillis() + 365 * 24 * 60 * 60 * 1000L); +// SubjectPublicKeyInfo publicKeyInfo = SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded()); +// X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder( +// issuer, +// BigInteger.valueOf(System.currentTimeMillis()), +// notBefore, +// notAfter, +// subject, +// publicKeyInfo +// ); +// ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); +// return new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer)); +// } +// +// private static void writeCertificateToFile(X509Certificate cert, File file) throws Exception { +// String certPem = "-----BEGIN CERTIFICATE-----\n" + +// Base64.getEncoder().encodeToString(cert.getEncoded()) + +// "\n-----END CERTIFICATE-----"; +// Files.write(file.toPath(), certPem.getBytes()); +// } +// +// private static void writePrivateKeyToFile(KeyPair keyPair, File file) throws Exception { +// String keyPem = "-----BEGIN PRIVATE KEY-----\n" + +// Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()) + +// "\n-----END PRIVATE KEY-----"; +// Files.write(file.toPath(), keyPem.getBytes()); +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithTls() throws Exception { +// // Generate test certificate and key +// KeyPair keyPair = generateKeyPair(); +// X509Certificate cert = generateCertificate(keyPair); +// +// File certFile = File.createTempFile("test-cert", ".pem"); +// File keyFile = File.createTempFile("test-key", ".pem"); +// try { +// writeCertificateToFile(cert, certFile); +// writePrivateKeyToFile(keyPair, keyFile); +// +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .tlsCertPath(certFile.getAbsolutePath()) +// .tlsKeyPath(keyFile.getAbsolutePath()) +// .build(); +// +// assertNotNull(client); +// // Note: We can't easily test the actual TLS configuration without a real server +// } finally { +// certFile.delete(); +// keyFile.delete(); +// } +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithTlsAndEndpoint() throws Exception { +// // Generate test certificate and key +// KeyPair keyPair = generateKeyPair(); +// X509Certificate cert = generateCertificate(keyPair); +// +// File certFile = File.createTempFile("test-cert", ".pem"); +// File keyFile = File.createTempFile("test-key", ".pem"); +// try { +// writeCertificateToFile(cert, certFile); +// writePrivateKeyToFile(keyPair, keyFile); +// +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .tlsCertPath(certFile.getAbsolutePath()) +// .tlsKeyPath(keyFile.getAbsolutePath()) +// .port(443) +// .build(); +// +// assertNotNull(client); +// } finally { +// certFile.delete(); +// keyFile.delete(); +// } +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithInvalidTlsCert() { +// assertThrows(RuntimeException.class, () -> { +// new DurableTaskGrpcClientBuilder() +// .tlsCertPath("/nonexistent/cert.pem") +// .tlsKeyPath("/nonexistent/key.pem") +// .build(); +// }); +// } +// +// @Test +// @EnabledOnOs({OS.LINUX, OS.MAC}) +// public void testBuildGrpcManagedChannelWithTlsAndUnixSocket() throws Exception { +// // Skip this test since Unix socket support is not implemented yet +// Assumptions.assumeTrue(false, "Unix socket support not implemented yet"); +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithTlsAndDnsAuthority() throws Exception { +// // Generate test certificate and key +// KeyPair keyPair = generateKeyPair(); +// X509Certificate cert = generateCertificate(keyPair); +// +// File certFile = File.createTempFile("test-cert", ".pem"); +// File keyFile = File.createTempFile("test-key", ".pem"); +// try { +// writeCertificateToFile(cert, certFile); +// writePrivateKeyToFile(keyPair, keyFile); +// +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .tlsCertPath(certFile.getAbsolutePath()) +// .tlsKeyPath(keyFile.getAbsolutePath()) +// .port(443) +// .build(); +// +// assertNotNull(client); +// } finally { +// certFile.delete(); +// keyFile.delete(); +// } +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithTlsAndCaCert() throws Exception { +// // Generate test CA certificate +// KeyPair caKeyPair = generateKeyPair(); +// X509Certificate caCert = generateCertificate(caKeyPair); +// +// File caCertFile = File.createTempFile("test-ca-cert", ".pem"); +// try { +// writeCertificateToFile(caCert, caCertFile); +// +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .tlsCaPath(caCertFile.getAbsolutePath()) +// .build(); +// +// assertNotNull(client); +// } finally { +// caCertFile.delete(); +// } +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithTlsAndCaCertAndEndpoint() throws Exception { +// // Generate test CA certificate +// KeyPair caKeyPair = generateKeyPair(); +// X509Certificate caCert = generateCertificate(caKeyPair); +// +// File caCertFile = File.createTempFile("test-ca-cert", ".pem"); +// try { +// writeCertificateToFile(caCert, caCertFile); +// +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .tlsCaPath(caCertFile.getAbsolutePath()) +// .port(443) +// .build(); +// +// assertNotNull(client); +// } finally { +// caCertFile.delete(); +// } +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithInvalidCaCert() { +// assertThrows(RuntimeException.class, () -> { +// new DurableTaskGrpcClientBuilder() +// .tlsCaPath("/nonexistent/ca.pem") +// .build(); +// }); +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithMtlsAndCaCert() throws Exception { +// // Generate test certificates +// KeyPair caKeyPair = generateKeyPair(); +// X509Certificate caCert = generateCertificate(caKeyPair); +// KeyPair clientKeyPair = generateKeyPair(); +// X509Certificate clientCert = generateCertificate(clientKeyPair); +// +// File caCertFile = File.createTempFile("test-ca-cert", ".pem"); +// File clientCertFile = File.createTempFile("test-client-cert", ".pem"); +// File clientKeyFile = File.createTempFile("test-client-key", ".pem"); +// try { +// writeCertificateToFile(caCert, caCertFile); +// writeCertificateToFile(clientCert, clientCertFile); +// writePrivateKeyToFile(clientKeyPair, clientKeyFile); +// +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .tlsCaPath(caCertFile.getAbsolutePath()) +// .tlsCertPath(clientCertFile.getAbsolutePath()) +// .tlsKeyPath(clientKeyFile.getAbsolutePath()) +// .build(); +// +// assertNotNull(client); +// } finally { +// caCertFile.delete(); +// clientCertFile.delete(); +// clientKeyFile.delete(); +// } +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithInsecureTls() throws Exception { +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .insecure(true) +// .port(443) +// .build(); +// +// assertNotNull(client); +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithInsecureTlsAndMtls() throws Exception { +// // Generate test certificates +// KeyPair caKeyPair = generateKeyPair(); +// X509Certificate caCert = generateCertificate(caKeyPair); +// KeyPair clientKeyPair = generateKeyPair(); +// X509Certificate clientCert = generateCertificate(clientKeyPair); +// +// File caCertFile = File.createTempFile("test-ca-cert", ".pem"); +// File clientCertFile = File.createTempFile("test-client-cert", ".pem"); +// File clientKeyFile = File.createTempFile("test-client-key", ".pem"); +// try { +// writeCertificateToFile(caCert, caCertFile); +// writeCertificateToFile(clientCert, clientCertFile); +// writePrivateKeyToFile(clientKeyPair, clientKeyFile); +// +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .insecure(true) +// .tlsCaPath(caCertFile.getAbsolutePath()) +// .tlsCertPath(clientCertFile.getAbsolutePath()) +// .tlsKeyPath(clientKeyFile.getAbsolutePath()) +// .port(443) +// .build(); +// +// assertNotNull(client); +// } finally { +// caCertFile.delete(); +// clientCertFile.delete(); +// clientKeyFile.delete(); +// } +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithInsecureTlsAndCustomEndpoint() throws Exception { +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .insecure(true) +// .port(443) +// .build(); +// +// assertNotNull(client); +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithPlaintext() throws Exception { +// // No TLS config provided, should use plaintext +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .port(443) +// .build(); +// +// assertNotNull(client); +// } +// +// @Test +// public void testBuildGrpcManagedChannelWithPlaintextAndCustomEndpoint() throws Exception { +// // No TLS config provided, should use plaintext +// client = (DurableTaskGrpcClient) new DurableTaskGrpcClientBuilder() +// .port(50001) // Custom port +// .build(); +// +// assertNotNull(client); +// } +//} \ No newline at end of file diff --git a/durabletask-client/src/test/java/io/dapr/durabletask/ErrorHandlingIT.java b/durabletask-client/src/test/java/io/dapr/durabletask/ErrorHandlingIT.java new file mode 100644 index 000000000..f1c868f0a --- /dev/null +++ b/durabletask-client/src/test/java/io/dapr/durabletask/ErrorHandlingIT.java @@ -0,0 +1,306 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.Duration; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * These integration tests are designed to exercise the core, high-level error-handling features of the Durable Task + * programming model. + *

+ * These tests currently require a sidecar process to be running on the local machine (the sidecar is what accepts the + * client operations and sends invocation instructions to the DurableTaskWorker). + */ +@Tag("integration") +public class ErrorHandlingIT extends IntegrationTestBase { + @Test + void orchestratorException() throws TimeoutException { + final String orchestratorName = "OrchestratorWithException"; + final String errorMessage = "Kah-BOOOOOM!!!"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + throw new RuntimeException(errorMessage); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus()); + + FailureDetails details = instance.getFailureDetails(); + assertNotNull(details); + assertEquals("java.lang.RuntimeException", details.getErrorType()); + assertTrue(details.getErrorMessage().contains(errorMessage)); + assertNotNull(details.getStackTrace()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void activityException(boolean handleException) throws TimeoutException { + final String orchestratorName = "OrchestratorWithActivityException"; + final String activityName = "Throw"; + final String errorMessage = "Kah-BOOOOOM!!!"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + try { + ctx.callActivity(activityName).await(); + } catch (TaskFailedException ex) { + if (handleException) { + ctx.complete("handled"); + } else { + throw ex; + } + } + }) + .addActivity(activityName, ctx -> { + throw new RuntimeException(errorMessage); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, ""); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + + if (handleException) { + String result = instance.readOutputAs(String.class); + assertNotNull(result); + assertEquals("handled", result); + } else { + assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus()); + + FailureDetails details = instance.getFailureDetails(); + assertNotNull(details); + + String expectedMessage = String.format( + "Task '%s' (#0) failed with an unhandled exception: %s", + activityName, + errorMessage); + assertEquals(expectedMessage, details.getErrorMessage()); + assertEquals("io.dapr.durabletask.TaskFailedException", details.getErrorType()); + assertNotNull(details.getStackTrace()); + // CONSIDER: Additional validation of getErrorDetails? + } + } + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 10}) + public void retryActivityFailures(int maxNumberOfAttempts) throws TimeoutException { + // There is one task for each activity call and one task between each retry + int expectedTaskCount = (maxNumberOfAttempts * 2) - 1; + this.retryOnFailuresCoreTest(maxNumberOfAttempts, expectedTaskCount, ctx -> { + RetryPolicy retryPolicy = getCommonRetryPolicy(maxNumberOfAttempts); + ctx.callActivity( + "BustedActivity", + null, + TaskOptions.withRetryPolicy(retryPolicy)).await(); + }); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 10}) + public void retryActivityFailuresWithCustomLogic(int maxNumberOfAttempts) throws TimeoutException { + // This gets incremented every time the retry handler is invoked + AtomicInteger retryHandlerCalls = new AtomicInteger(); + + // Run the test and get back the details of the last failure + this.retryOnFailuresCoreTest(maxNumberOfAttempts, maxNumberOfAttempts, ctx -> { + RetryHandler retryHandler = getCommonRetryHandler(retryHandlerCalls, maxNumberOfAttempts); + TaskOptions options = TaskOptions.withRetryHandler(retryHandler); + ctx.callActivity("BustedActivity", null, options).await(); + }); + + // Assert that the retry handle got invoked the expected number of times + assertEquals(maxNumberOfAttempts, retryHandlerCalls.get()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void subOrchestrationException(boolean handleException) throws TimeoutException { + final String orchestratorName = "OrchestrationWithBustedSubOrchestrator"; + final String subOrchestratorName = "BustedSubOrchestrator"; + final String errorMessage = "Kah-BOOOOOM!!!"; + + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, ctx -> { + try { + String result = ctx.callSubOrchestrator(subOrchestratorName, "", String.class).await(); + ctx.complete(result); + } catch (TaskFailedException ex) { + if (handleException) { + ctx.complete("handled"); + } else { + throw ex; + } + } + }) + .addOrchestrator(subOrchestratorName, ctx -> { + throw new RuntimeException(errorMessage); + }) + .buildAndStart(); + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 1); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + if (handleException) { + assertEquals(OrchestrationRuntimeStatus.COMPLETED, instance.getRuntimeStatus()); + String result = instance.readOutputAs(String.class); + assertNotNull(result); + assertEquals("handled", result); + } else { + assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus()); + FailureDetails details = instance.getFailureDetails(); + assertNotNull(details); + String expectedMessage = String.format( + "Task '%s' (#0) failed with an unhandled exception: %s", + subOrchestratorName, + errorMessage); + assertEquals(expectedMessage, details.getErrorMessage()); + assertEquals("io.dapr.durabletask.TaskFailedException", details.getErrorType()); + assertNotNull(details.getStackTrace()); + // CONSIDER: Additional validation of getStackTrace? + } + } + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 10}) + public void retrySubOrchestratorFailures(int maxNumberOfAttempts) throws TimeoutException { + // There is one task for each sub-orchestrator call and one task between each retry + int expectedTaskCount = (maxNumberOfAttempts * 2) - 1; + this.retryOnFailuresCoreTest(maxNumberOfAttempts, expectedTaskCount, ctx -> { + RetryPolicy retryPolicy = getCommonRetryPolicy(maxNumberOfAttempts); + ctx.callSubOrchestrator( + "BustedSubOrchestrator", + null, + null, + TaskOptions.withRetryPolicy(retryPolicy)).await(); + }); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 10}) + public void retrySubOrchestrationFailuresWithCustomLogic(int maxNumberOfAttempts) throws TimeoutException { + // This gets incremented every time the retry handler is invoked + AtomicInteger retryHandlerCalls = new AtomicInteger(); + + // Run the test and get back the details of the last failure + this.retryOnFailuresCoreTest(maxNumberOfAttempts, maxNumberOfAttempts, ctx -> { + RetryHandler retryHandler = getCommonRetryHandler(retryHandlerCalls, maxNumberOfAttempts); + TaskOptions options = TaskOptions.withRetryHandler(retryHandler); + ctx.callSubOrchestrator("BustedSubOrchestrator", null, null, options).await(); + }); + + // Assert that the retry handle got invoked the expected number of times + assertEquals(maxNumberOfAttempts, retryHandlerCalls.get()); + } + + private static RetryPolicy getCommonRetryPolicy(int maxNumberOfAttempts) { + // Include a small delay between each retry to exercise the implicit timer path + return new RetryPolicy(maxNumberOfAttempts, Duration.ofMillis(1)); + } + + private static RetryHandler getCommonRetryHandler(AtomicInteger handlerInvocationCounter, int maxNumberOfAttempts) { + return ctx -> { + // Retry handlers get executed on the orchestrator thread and go through replay + if (!ctx.getOrchestrationContext().getIsReplaying()) { + handlerInvocationCounter.getAndIncrement(); + } + + // The isCausedBy() method is designed to handle exception inheritance + if (!ctx.getLastFailure().isCausedBy(Exception.class)) { + return false; + } + + // This is the actual exception type we care about + if (!ctx.getLastFailure().isCausedBy(RuntimeException.class)) { + return false; + } + + // Quit after N attempts + return ctx.getLastAttemptNumber() < maxNumberOfAttempts; + }; + } + + /** + * Shared logic for execution an orchestration with an activity that constantly fails. + * + * @param maxNumberOfAttempts The expected maximum number of activity execution attempts + * @param expectedTaskCount The expected number of tasks to be scheduled by the main orchestration. + * @param mainOrchestration The main orchestration implementation, which is expected to call either the + * "BustedActivity" activity or the "BustedSubOrchestrator" sub-orchestration. + * @return Returns the details of the last activity or sub-orchestration failure. + */ + private FailureDetails retryOnFailuresCoreTest( + int maxNumberOfAttempts, + int expectedTaskCount, + TaskOrchestration mainOrchestration) throws TimeoutException { + final String orchestratorName = "MainOrchestrator"; + + AtomicInteger actualAttemptCount = new AtomicInteger(); + + // The caller of this test provides the top-level orchestration implementation. This method provides both a + // failing sub-orchestration and a failing activity implementation for it to use. The expectation is that the + // main orchestration tries to invoke just one of them and is configured with retry configuration. + AtomicBoolean isActivityPath = new AtomicBoolean(false); + DurableTaskGrpcWorker worker = this.createWorkerBuilder() + .addOrchestrator(orchestratorName, mainOrchestration) + .addOrchestrator("BustedSubOrchestrator", ctx -> { + actualAttemptCount.getAndIncrement(); + throw new RuntimeException("Error #" + actualAttemptCount.get()); + }) + .addActivity("BustedActivity", ctx -> { + actualAttemptCount.getAndIncrement(); + isActivityPath.set(true); + throw new RuntimeException("Error #" + actualAttemptCount.get()); + }) + .buildAndStart(); + + DurableTaskClient client = new DurableTaskGrpcClientBuilder().build(); + try (worker; client) { + String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, ""); + OrchestrationMetadata instance = client.waitForInstanceCompletion(instanceId, defaultTimeout, true); + assertNotNull(instance); + assertEquals(OrchestrationRuntimeStatus.FAILED, instance.getRuntimeStatus()); + + // Make sure the exception details are still what we expect + FailureDetails details = instance.getFailureDetails(); + assertNotNull(details); + + // Confirm the number of attempts + assertEquals(maxNumberOfAttempts, actualAttemptCount.get()); + + return details; + } + } +} \ No newline at end of file diff --git a/durabletask-client/src/test/java/io/dapr/durabletask/IntegrationTestBase.java b/durabletask-client/src/test/java/io/dapr/durabletask/IntegrationTestBase.java new file mode 100644 index 000000000..bbfcde046 --- /dev/null +++ b/durabletask-client/src/test/java/io/dapr/durabletask/IntegrationTestBase.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import org.junit.jupiter.api.AfterEach; + +import java.time.Duration; + +public class IntegrationTestBase { + protected static final Duration defaultTimeout = Duration.ofSeconds(10); + + // All tests that create a server should save it to this variable for proper shutdown + private DurableTaskGrpcWorker server; + + @AfterEach + public void shutdown() { + if (this.server != null) { + this.server.stop(); + } + } + + + protected TestDurableTaskWorkerBuilder createWorkerBuilder() { + return new TestDurableTaskWorkerBuilder(); + } + + public class TestDurableTaskWorkerBuilder { + final DurableTaskGrpcWorkerBuilder innerBuilder; + + private TestDurableTaskWorkerBuilder() { + this.innerBuilder = new DurableTaskGrpcWorkerBuilder(); + } + + public DurableTaskGrpcWorker buildAndStart() { + DurableTaskGrpcWorker server = this.innerBuilder.build(); + IntegrationTestBase.this.server = server; + server.start(); + return server; + } + + public TestDurableTaskWorkerBuilder setMaximumTimerInterval(Duration maximumTimerInterval) { + this.innerBuilder.maximumTimerInterval(maximumTimerInterval); + return this; + } + + public TestDurableTaskWorkerBuilder addOrchestrator( + String name, + TaskOrchestration implementation) { + this.innerBuilder.addOrchestration(new TaskOrchestrationFactory() { + @Override + public String getName() { + return name; + } + + @Override + public TaskOrchestration create() { + return implementation; + } + }); + return this; + } + + public TestDurableTaskWorkerBuilder addActivity( + String name, + TaskActivity implementation) { + this.innerBuilder.addActivity(new TaskActivityFactory() { + @Override + public String getName() { + return name; + } + + @Override + public TaskActivity create() { + return implementation; + } + }); + return this; + } + } +} diff --git a/durabletask-client/src/test/java/io/dapr/durabletask/TaskOptionsTest.java b/durabletask-client/src/test/java/io/dapr/durabletask/TaskOptionsTest.java new file mode 100644 index 000000000..43fad5f52 --- /dev/null +++ b/durabletask-client/src/test/java/io/dapr/durabletask/TaskOptionsTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2025 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.durabletask; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for TaskOptions with cross-app workflow support. + */ +public class TaskOptionsTest { + + @Test + void taskOptionsWithAppID() { + TaskOptions options = TaskOptions.withAppID("app1"); + + assertTrue(options.hasAppID()); + assertEquals("app1", options.getAppID()); + assertFalse(options.hasRetryPolicy()); + assertFalse(options.hasRetryHandler()); + } + + @Test + void taskOptionsWithRetryPolicyAndAppID() { + RetryPolicy retryPolicy = new RetryPolicy(3, Duration.ofSeconds(1)); + TaskOptions options = TaskOptions.builder() + .retryPolicy(retryPolicy) + .appID("app2") + .build(); + + assertTrue(options.hasAppID()); + assertEquals("app2", options.getAppID()); + assertTrue(options.hasRetryPolicy()); + assertEquals(retryPolicy, options.getRetryPolicy()); + assertFalse(options.hasRetryHandler()); + } + + @Test + void taskOptionsWithRetryHandlerAndAppID() { + RetryHandler retryHandler = new RetryHandler() { + @Override + public boolean handle(RetryContext context) { + return context.getLastAttemptNumber() < 2; + } + }; + TaskOptions options = TaskOptions.builder() + .retryHandler(retryHandler) + .appID("app3") + .build(); + + assertTrue(options.hasAppID()); + assertEquals("app3", options.getAppID()); + assertFalse(options.hasRetryPolicy()); + assertTrue(options.hasRetryHandler()); + assertEquals(retryHandler, options.getRetryHandler()); + } + + @Test + void taskOptionsWithoutAppID() { + TaskOptions options = TaskOptions.create(); + + assertFalse(options.hasAppID()); + assertNull(options.getAppID()); + } + + @Test + void taskOptionsWithEmptyAppID() { + TaskOptions options = TaskOptions.withAppID(""); + + assertFalse(options.hasAppID()); + assertEquals("", options.getAppID()); + } + + @Test + void taskOptionsWithNullAppID() { + TaskOptions options = TaskOptions.builder().appID(null).build(); + + assertFalse(options.hasAppID()); + assertNull(options.getAppID()); + } + + @Test + void taskOptionsWithRetryPolicy() { + RetryPolicy retryPolicy = new RetryPolicy(5, Duration.ofMinutes(1)); + TaskOptions options = TaskOptions.withRetryPolicy(retryPolicy); + + assertTrue(options.hasRetryPolicy()); + assertEquals(retryPolicy, options.getRetryPolicy()); + assertFalse(options.hasRetryHandler()); + assertFalse(options.hasAppID()); + } + + @Test + void taskOptionsWithRetryHandler() { + RetryHandler retryHandler = new RetryHandler() { + @Override + public boolean handle(RetryContext context) { + return context.getLastAttemptNumber() < 3; + } + }; + TaskOptions options = TaskOptions.withRetryHandler(retryHandler); + + assertTrue(options.hasRetryHandler()); + assertEquals(retryHandler, options.getRetryHandler()); + assertFalse(options.hasRetryPolicy()); + assertFalse(options.hasAppID()); + } + + @Test + void taskOptionsWithBuilderChaining() { + RetryPolicy retryPolicy = new RetryPolicy(3, Duration.ofSeconds(1)); + RetryHandler retryHandler = context -> true; + + TaskOptions options = TaskOptions.builder() + .retryPolicy(retryPolicy) + .retryHandler(retryHandler) + .appID("test-app") + .build(); + + assertNotNull(options); + assertTrue(options.hasRetryPolicy()); + assertEquals(retryPolicy, options.getRetryPolicy()); + assertTrue(options.hasRetryHandler()); + assertEquals(retryHandler, options.getRetryHandler()); + assertTrue(options.hasAppID()); + assertEquals("test-app", options.getAppID()); + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 0a6a5360e..6567f7a82 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ 1.69.0 3.25.5 https://raw.githubusercontent.com/dapr/dapr/v1.16.0-rc.5/dapr/proto + https://raw.githubusercontent.com/dapr/durabletask-protobuf/main/protos/orchestrator_service.proto 1.17.0-SNAPSHOT 0.17.0-SNAPSHOT 1.7.1 @@ -37,7 +38,7 @@ which conflict with dapr-sdk's jackson dependencies https://github.com/dapr/durabletask-java/blob/main/client/build.gradle#L16 --> - 2.16.1 + 2.16.2 true true ../spotbugs-exclude.xml @@ -471,6 +472,11 @@ jackson-annotations ${jackson.version} + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + io.projectreactor reactor-core @@ -783,6 +789,7 @@ spring-boot-examples testcontainers-dapr + durabletask-client @@ -791,6 +798,7 @@ sdk-tests spring-boot-examples + durabletask-client From 231210ffcfd64ebe69feb0dba69fc9f2a7c32c4c Mon Sep 17 00:00:00 2001 From: Matheus Cruz <56329339+mcruzdev@users.noreply.github.com> Date: Sun, 19 Oct 2025 21:21:57 -0300 Subject: [PATCH 2/8] Replace openjdk:17-jdk-slim to eclipse-temurin:17-jdk-jammy (#1574) Signed-off-by: Matheus Cruz Signed-off-by: salaboy --- .../java/io/dapr/it/testcontainers/ContainerConstants.java | 1 + .../multiapp/WorkflowsMultiAppCallActivityIT.java | 7 ++++--- .../examples/orchestrator/DaprTestContainersConfig.java | 4 ++-- .../springboot/examples/orchestrator/DockerImages.java | 6 ++++++ 4 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/DockerImages.java diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/ContainerConstants.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/ContainerConstants.java index e66f81285..78f2d3f46 100644 --- a/sdk-tests/src/test/java/io/dapr/it/testcontainers/ContainerConstants.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/ContainerConstants.java @@ -7,4 +7,5 @@ public interface ContainerConstants { String DAPR_PLACEMENT_IMAGE_TAG = DaprContainerConstants.DAPR_PLACEMENT_IMAGE_TAG; String DAPR_SCHEDULER_IMAGE_TAG = DaprContainerConstants.DAPR_SCHEDULER_IMAGE_TAG; String TOXI_PROXY_IMAGE_TAG = "ghcr.io/shopify/toxiproxy:2.5.0"; + String JDK_17_TEMURIN_JAMMY = "eclipse-temurin:17-jdk-jammy"; } diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/multiapp/WorkflowsMultiAppCallActivityIT.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/multiapp/WorkflowsMultiAppCallActivityIT.java index dfa591abf..a36a77c3e 100644 --- a/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/multiapp/WorkflowsMultiAppCallActivityIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/multiapp/WorkflowsMultiAppCallActivityIT.java @@ -13,6 +13,7 @@ package io.dapr.it.testcontainers.workflows.multiapp; +import io.dapr.it.testcontainers.ContainerConstants; import io.dapr.testcontainers.Component; import io.dapr.testcontainers.DaprContainer; import io.dapr.testcontainers.DaprLogLevel; @@ -113,7 +114,7 @@ public class WorkflowsMultiAppCallActivityIT { // TestContainers for each app @Container - private static GenericContainer multiappWorker = new GenericContainer<>("openjdk:17-jdk-slim") + private static GenericContainer multiappWorker = new GenericContainer<>(ContainerConstants.JDK_17_TEMURIN_JAMMY) .withCopyFileToContainer(MountableFile.forHostPath("target"), "/app") .withWorkingDirectory("/app") .withCommand("java", "-cp", "test-classes:classes:dependency/*:*", @@ -127,7 +128,7 @@ public class WorkflowsMultiAppCallActivityIT { .withLogConsumer(outputFrame -> System.out.println("MultiAppWorker: " + outputFrame.getUtf8String())); @Container - private final static GenericContainer app2Worker = new GenericContainer<>("openjdk:17-jdk-slim") + private final static GenericContainer app2Worker = new GenericContainer<>(ContainerConstants.JDK_17_TEMURIN_JAMMY) .withCopyFileToContainer(MountableFile.forHostPath("target"), "/app") .withWorkingDirectory("/app") .withCommand("java", "-cp", "test-classes:classes:dependency/*:*", @@ -141,7 +142,7 @@ public class WorkflowsMultiAppCallActivityIT { .withLogConsumer(outputFrame -> System.out.println("App2Worker: " + outputFrame.getUtf8String())); @Container - private final static GenericContainer app3Worker = new GenericContainer<>("openjdk:17-jdk-slim") + private final static GenericContainer app3Worker = new GenericContainer<>(ContainerConstants.JDK_17_TEMURIN_JAMMY) .withCopyFileToContainer(MountableFile.forHostPath("target"), "/app") .withWorkingDirectory("/app") .withCommand("java", "-cp", "test-classes:classes:dependency/*:*", diff --git a/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/DaprTestContainersConfig.java b/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/DaprTestContainersConfig.java index efdb511c8..11695a134 100644 --- a/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/DaprTestContainersConfig.java +++ b/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/DaprTestContainersConfig.java @@ -123,7 +123,7 @@ public GenericContainer workerOneContainer(Network daprNetwork, @Qualifier("workerOneDapr") DaprContainer workerOneDapr, DaprPlacementContainer daprPlacementContainer, DaprSchedulerContainer daprSchedulerContainer){ - return new GenericContainer<>("openjdk:17-jdk-slim") + return new GenericContainer<>(DockerImages.JDK_17_TEMURIN_JAMMY) .withCopyFileToContainer(MountableFile.forHostPath("../worker-one/target"), "/app") .withWorkingDirectory("/app") .withCommand("java", @@ -165,7 +165,7 @@ public GenericContainer workerTwoContainer(Network daprNetwork, @Qualifier("workerTwoDapr") DaprContainer workerTwoDapr, DaprPlacementContainer daprPlacementContainer, DaprSchedulerContainer daprSchedulerContainer){ - return new GenericContainer<>("openjdk:17-jdk-slim") + return new GenericContainer<>(DockerImages.JDK_17_TEMURIN_JAMMY) .withCopyFileToContainer(MountableFile.forHostPath("../worker-two/target"), "/app") .withWorkingDirectory("/app") .withCommand("java", diff --git a/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/DockerImages.java b/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/DockerImages.java new file mode 100644 index 000000000..7291bd1b9 --- /dev/null +++ b/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/DockerImages.java @@ -0,0 +1,6 @@ +package io.dapr.springboot.examples.orchestrator; + +public interface DockerImages { + + String JDK_17_TEMURIN_JAMMY = "eclipse-temurin:17-jdk-jammy"; +} From 501c5335940d2dea97c64b62dfb3156cd780357e Mon Sep 17 00:00:00 2001 From: Matheus Cruz <56329339+mcruzdev@users.noreply.github.com> Date: Mon, 20 Oct 2025 01:04:04 -0300 Subject: [PATCH 3/8] Align Java API with other languages (#1560) * Align Java API with other languages Signed-off-by: Matheus Cruz * Update documentation Signed-off-by: Matheus Cruz * Change return type of waitForWorkflowStart method Signed-off-by: artur-ciocanu --------- Signed-off-by: Matheus Cruz Signed-off-by: artur-ciocanu Co-authored-by: artur-ciocanu Signed-off-by: salaboy --- .../en/java-sdk-docs/java-client/_index.md | 30 +-- .../java-workflow/java-workflow-howto.md | 32 +-- .../workflows/chain/DemoChainClient.java | 8 +- .../DemoChildWorkerflowClient.java | 8 +- .../compensation/BookTripClient.java | 4 +- .../DemoContinueAsNewClient.java | 2 +- .../DemoExternalEventClient.java | 2 +- .../faninout/DemoFanInOutClient.java | 6 +- .../multiapp/MultiAppWorkflowClient.java | 6 +- .../DemoSuspendResumeClient.java | 8 +- .../workflows/DaprWorkflowsIT.java | 22 +- .../WorkflowsMultiAppCallActivityIT.java | 7 +- .../workflows/client/DaprWorkflowClient.java | 102 ++++++++- .../client/WorkflowInstanceStatus.java | 2 + .../dapr/workflows/client/WorkflowState.java | 142 ++++++++++++ .../DefaultWorkflowInstanceStatus.java | 2 + .../runtime/DefaultWorkflowState.java | 210 +++++++++++++++++ .../client/DaprWorkflowClientTest.java | 14 +- .../workflows/client/WorkflowStateTest.java | 213 ++++++++++++++++++ .../orchestrator/CustomersRestController.java | 7 +- .../orchestrator/OrchestratorAppIT.java | 2 - .../wfp/WorkflowPatternsRestController.java | 30 +-- 22 files changed, 760 insertions(+), 99 deletions(-) create mode 100644 sdk-workflows/src/main/java/io/dapr/workflows/client/WorkflowState.java create mode 100644 sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowState.java create mode 100644 sdk-workflows/src/test/java/io/dapr/workflows/client/WorkflowStateTest.java diff --git a/daprdocs/content/en/java-sdk-docs/java-client/_index.md b/daprdocs/content/en/java-sdk-docs/java-client/_index.md index 8199824a2..c162b16be 100644 --- a/daprdocs/content/en/java-sdk-docs/java-client/_index.md +++ b/daprdocs/content/en/java-sdk-docs/java-client/_index.md @@ -486,7 +486,7 @@ public class DistributedLockGrpcClient { package io.dapr.examples.workflows; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import java.time.Duration; import java.util.concurrent.TimeUnit; @@ -513,18 +513,18 @@ public class DemoWorkflowClient { System.out.printf("Started new workflow instance with random ID: %s%n", instanceId); System.out.println(separatorStr); - System.out.println("**GetInstanceMetadata:Running Workflow**"); - WorkflowInstanceStatus workflowMetadata = client.getInstanceState(instanceId, true); + System.out.println("**GetWorkflowMetadata:Running Workflow**"); + WorkflowState workflowMetadata = client.getWorkflowState(instanceId, true); System.out.printf("Result: %s%n", workflowMetadata); System.out.println(separatorStr); - System.out.println("**WaitForInstanceStart**"); + System.out.println("**WaitForWorkflowStart**"); try { - WorkflowInstanceStatus waitForInstanceStartResult = - client.waitForInstanceStart(instanceId, Duration.ofSeconds(60), true); - System.out.printf("Result: %s%n", waitForInstanceStartResult); + WorkflowState waitForWorkflowStartResult = + client.waitForWorkflowStart(instanceId, Duration.ofSeconds(60), true); + System.out.printf("Result: %s%n", waitForWorkflowStartResult); } catch (TimeoutException ex) { - System.out.printf("waitForInstanceStart has an exception:%s%n", ex); + System.out.printf("waitForWorkflowStart has an exception:%s%n", ex); } System.out.println(separatorStr); @@ -545,18 +545,18 @@ public class DemoWorkflowClient { System.out.println(separatorStr); - System.out.println("**WaitForInstanceCompletion**"); + System.out.println("**waitForWorkflowCompletion**"); try { - WorkflowInstanceStatus waitForInstanceCompletionResult = - client.waitForInstanceCompletion(instanceId, Duration.ofSeconds(60), true); - System.out.printf("Result: %s%n", waitForInstanceCompletionResult); + WorkflowState waitForWorkflowCompletionResult = + client.waitForWorkflowCompletion(instanceId, Duration.ofSeconds(60), true); + System.out.printf("Result: %s%n", waitForWorkflowCompletionResult); } catch (TimeoutException ex) { - System.out.printf("waitForInstanceCompletion has an exception:%s%n", ex); + System.out.printf("waitForWorkflowCompletion has an exception:%s%n", ex); } System.out.println(separatorStr); - System.out.println("**purgeInstance**"); - boolean purgeResult = client.purgeInstance(instanceId); + System.out.println("**purgeWorkflow**"); + boolean purgeResult = client.purgeWorkflow(instanceId); System.out.printf("purgeResult: %s%n", purgeResult); System.out.println(separatorStr); diff --git a/daprdocs/content/en/java-sdk-docs/java-workflow/java-workflow-howto.md b/daprdocs/content/en/java-sdk-docs/java-workflow/java-workflow-howto.md index ccc365cf4..79c6e06d0 100644 --- a/daprdocs/content/en/java-sdk-docs/java-workflow/java-workflow-howto.md +++ b/daprdocs/content/en/java-sdk-docs/java-workflow/java-workflow-howto.md @@ -104,17 +104,17 @@ public class DemoWorkflowClient { System.out.println(separatorStr); System.out.println("**GetInstanceMetadata:Running Workflow**"); - WorkflowInstanceStatus workflowMetadata = client.getInstanceState(instanceId, true); + WorkflowState workflowMetadata = client.getWorkflowState(instanceId, true); System.out.printf("Result: %s%n", workflowMetadata); System.out.println(separatorStr); - System.out.println("**WaitForInstanceStart**"); + System.out.println("**WaitForWorkflowStart**"); try { - WorkflowInstanceStatus waitForInstanceStartResult = - client.waitForInstanceStart(instanceId, Duration.ofSeconds(60), true); - System.out.printf("Result: %s%n", waitForInstanceStartResult); + WorkflowState waitForWorkflowStartResult = + client.waitForWorkflowStart(instanceId, Duration.ofSeconds(60), true); + System.out.printf("Result: %s%n", waitForWorkflowStartResult); } catch (TimeoutException ex) { - System.out.printf("waitForInstanceStart has an exception:%s%n", ex); + System.out.printf("waitForWorkflowStart has an exception:%s%n", ex); } System.out.println(separatorStr); @@ -135,18 +135,18 @@ public class DemoWorkflowClient { System.out.println(separatorStr); - System.out.println("**WaitForInstanceCompletion**"); + System.out.println("**waitForWorkflowCompletion**"); try { - WorkflowInstanceStatus waitForInstanceCompletionResult = - client.waitForInstanceCompletion(instanceId, Duration.ofSeconds(60), true); - System.out.printf("Result: %s%n", waitForInstanceCompletionResult); + WorkflowState waitForWorkflowCompletionResult = + client.waitForWorkflowCompletion(instanceId, Duration.ofSeconds(60), true); + System.out.printf("Result: %s%n", waitForWorkflowCompletionResult); } catch (TimeoutException ex) { - System.out.printf("waitForInstanceCompletion has an exception:%s%n", ex); + System.out.printf("waitForWorkflowCompletion has an exception:%s%n", ex); } System.out.println(separatorStr); - System.out.println("**purgeInstance**"); - boolean purgeResult = client.purgeInstance(instanceId); + System.out.println("**purgeWorkflow**"); + boolean purgeResult = client.purgeWorkflow(instanceId); System.out.printf("purgeResult: %s%n", purgeResult); System.out.println(separatorStr); @@ -202,7 +202,7 @@ Started new workflow instance with random ID: 0b4cc0d5-413a-4c1c-816a-a71fa24740 **GetInstanceMetadata:Running Workflow** Result: [Name: 'io.dapr.examples.workflows.DemoWorkflow', ID: '0b4cc0d5-413a-4c1c-816a-a71fa24740d4', RuntimeStatus: RUNNING, CreatedAt: 2023-09-13T13:02:30.547Z, LastUpdatedAt: 2023-09-13T13:02:30.699Z, Input: '"input data"', Output: ''] ******* -**WaitForInstanceStart** +**WaitForWorkflowStart** Result: [Name: 'io.dapr.examples.workflows.DemoWorkflow', ID: '0b4cc0d5-413a-4c1c-816a-a71fa24740d4', RuntimeStatus: RUNNING, CreatedAt: 2023-09-13T13:02:30.547Z, LastUpdatedAt: 2023-09-13T13:02:30.699Z, Input: '"input data"', Output: ''] ******* **SendExternalMessage** @@ -213,10 +213,10 @@ Events raised for workflow with instanceId: 0b4cc0d5-413a-4c1c-816a-a71fa24740d4 ** Registering Event to be captured by anyOf(t1,t2,t3) ** Event raised for workflow with instanceId: 0b4cc0d5-413a-4c1c-816a-a71fa24740d4 ******* -**WaitForInstanceCompletion** +**WaitForWorkflowCompletion** Result: [Name: 'io.dapr.examples.workflows.DemoWorkflow', ID: '0b4cc0d5-413a-4c1c-816a-a71fa24740d4', RuntimeStatus: FAILED, CreatedAt: 2023-09-13T13:02:30.547Z, LastUpdatedAt: 2023-09-13T13:02:55.054Z, Input: '"input data"', Output: ''] ******* -**purgeInstance** +**purgeWorkflow** purgeResult: true ******* **raiseEvent** diff --git a/examples/src/main/java/io/dapr/examples/workflows/chain/DemoChainClient.java b/examples/src/main/java/io/dapr/examples/workflows/chain/DemoChainClient.java index 334e40f8d..b59544f0e 100644 --- a/examples/src/main/java/io/dapr/examples/workflows/chain/DemoChainClient.java +++ b/examples/src/main/java/io/dapr/examples/workflows/chain/DemoChainClient.java @@ -16,7 +16,7 @@ import io.dapr.examples.workflows.utils.PropertyUtils; import io.dapr.examples.workflows.utils.RetryUtils; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import java.time.Duration; import java.util.concurrent.TimeoutException; @@ -34,10 +34,10 @@ public static void main(String[] args) { Duration.ofSeconds(60)); System.out.printf("Started a new chaining model workflow with instance ID: %s%n", instanceId); - WorkflowInstanceStatus workflowInstanceStatus = - client.waitForInstanceCompletion(instanceId, null, true); + WorkflowState workflowState = + client.waitForWorkflowCompletion(instanceId, null, true); - String result = workflowInstanceStatus.readOutputAs(String.class); + String result = workflowState.readOutputAs(String.class); System.out.printf("workflow instance with ID: %s completed with result: %s%n", instanceId, result); } catch (TimeoutException | InterruptedException e) { throw new RuntimeException(e); diff --git a/examples/src/main/java/io/dapr/examples/workflows/childworkflow/DemoChildWorkerflowClient.java b/examples/src/main/java/io/dapr/examples/workflows/childworkflow/DemoChildWorkerflowClient.java index 80f647c17..b09df8ad7 100644 --- a/examples/src/main/java/io/dapr/examples/workflows/childworkflow/DemoChildWorkerflowClient.java +++ b/examples/src/main/java/io/dapr/examples/workflows/childworkflow/DemoChildWorkerflowClient.java @@ -15,7 +15,7 @@ import io.dapr.examples.workflows.utils.PropertyUtils; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import java.util.concurrent.TimeoutException; @@ -30,10 +30,10 @@ public static void main(String[] args) { try (DaprWorkflowClient client = new DaprWorkflowClient(PropertyUtils.getProperties(args))) { String instanceId = client.scheduleNewWorkflow(DemoWorkflow.class); System.out.printf("Started a new child-workflow model workflow with instance ID: %s%n", instanceId); - WorkflowInstanceStatus workflowInstanceStatus = - client.waitForInstanceCompletion(instanceId, null, true); + WorkflowState workflowState = + client.waitForWorkflowCompletion(instanceId, null, true); - String result = workflowInstanceStatus.readOutputAs(String.class); + String result = workflowState.readOutputAs(String.class); System.out.printf("workflow instance with ID: %s completed with result: %s%n", instanceId, result); } catch (TimeoutException | InterruptedException e) { diff --git a/examples/src/main/java/io/dapr/examples/workflows/compensation/BookTripClient.java b/examples/src/main/java/io/dapr/examples/workflows/compensation/BookTripClient.java index b7c4760e5..d827c99e6 100644 --- a/examples/src/main/java/io/dapr/examples/workflows/compensation/BookTripClient.java +++ b/examples/src/main/java/io/dapr/examples/workflows/compensation/BookTripClient.java @@ -16,7 +16,7 @@ import io.dapr.examples.workflows.utils.PropertyUtils; import io.dapr.examples.workflows.utils.RetryUtils; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import java.time.Duration; import java.util.concurrent.TimeoutException; @@ -27,7 +27,7 @@ public static void main(String[] args) { String instanceId = RetryUtils.callWithRetry(() -> client.scheduleNewWorkflow(BookTripWorkflow.class), Duration.ofSeconds(60)); System.out.printf("Started a new trip booking workflow with instance ID: %s%n", instanceId); - WorkflowInstanceStatus status = client.waitForInstanceCompletion(instanceId, Duration.ofMinutes(30), true); + WorkflowState status = client.waitForWorkflowCompletion(instanceId, Duration.ofMinutes(30), true); System.out.printf("Workflow instance with ID: %s completed with status: %s%n", instanceId, status); System.out.printf("Workflow output: %s%n", status.getSerializedOutput()); } catch (TimeoutException | InterruptedException e) { diff --git a/examples/src/main/java/io/dapr/examples/workflows/continueasnew/DemoContinueAsNewClient.java b/examples/src/main/java/io/dapr/examples/workflows/continueasnew/DemoContinueAsNewClient.java index 5827fa2c2..99b52fc86 100644 --- a/examples/src/main/java/io/dapr/examples/workflows/continueasnew/DemoContinueAsNewClient.java +++ b/examples/src/main/java/io/dapr/examples/workflows/continueasnew/DemoContinueAsNewClient.java @@ -30,7 +30,7 @@ public static void main(String[] args) { String instanceId = client.scheduleNewWorkflow(DemoContinueAsNewWorkflow.class); System.out.printf("Started a new continue-as-new model workflow with instance ID: %s%n", instanceId); - client.waitForInstanceCompletion(instanceId, null, true); + client.waitForWorkflowCompletion(instanceId, null, true); System.out.printf("workflow instance with ID: %s completed.", instanceId); } catch (TimeoutException | InterruptedException e) { diff --git a/examples/src/main/java/io/dapr/examples/workflows/externalevent/DemoExternalEventClient.java b/examples/src/main/java/io/dapr/examples/workflows/externalevent/DemoExternalEventClient.java index f827f2f70..9d4bda455 100644 --- a/examples/src/main/java/io/dapr/examples/workflows/externalevent/DemoExternalEventClient.java +++ b/examples/src/main/java/io/dapr/examples/workflows/externalevent/DemoExternalEventClient.java @@ -33,7 +33,7 @@ public static void main(String[] args) { client.raiseEvent(instanceId, "Approval", true); //client.raiseEvent(instanceId, "Approval", false); - client.waitForInstanceCompletion(instanceId, null, true); + client.waitForWorkflowCompletion(instanceId, null, true); System.out.printf("workflow instance with ID: %s completed.", instanceId); } catch (TimeoutException | InterruptedException e) { diff --git a/examples/src/main/java/io/dapr/examples/workflows/faninout/DemoFanInOutClient.java b/examples/src/main/java/io/dapr/examples/workflows/faninout/DemoFanInOutClient.java index 871b15cfe..8346b957c 100644 --- a/examples/src/main/java/io/dapr/examples/workflows/faninout/DemoFanInOutClient.java +++ b/examples/src/main/java/io/dapr/examples/workflows/faninout/DemoFanInOutClient.java @@ -16,7 +16,7 @@ import io.dapr.examples.workflows.utils.PropertyUtils; import io.dapr.examples.workflows.utils.RetryUtils; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import java.time.Duration; import java.util.Arrays; @@ -48,12 +48,12 @@ public static void main(String[] args) throws InterruptedException { System.out.printf("Started a new fan out/fan in model workflow with instance ID: %s%n", instanceId); // Block until the orchestration completes. Then print the final status, which includes the output. - WorkflowInstanceStatus workflowInstanceStatus = client.waitForInstanceCompletion( + WorkflowState workflowState = client.waitForWorkflowCompletion( instanceId, Duration.ofSeconds(30), true); System.out.printf("workflow instance with ID: %s completed with result: %s%n", instanceId, - workflowInstanceStatus.readOutputAs(int.class)); + workflowState.readOutputAs(int.class)); } catch (TimeoutException e) { throw new RuntimeException(e); } diff --git a/examples/src/main/java/io/dapr/examples/workflows/multiapp/MultiAppWorkflowClient.java b/examples/src/main/java/io/dapr/examples/workflows/multiapp/MultiAppWorkflowClient.java index 63ec49ca2..dfac32b71 100644 --- a/examples/src/main/java/io/dapr/examples/workflows/multiapp/MultiAppWorkflowClient.java +++ b/examples/src/main/java/io/dapr/examples/workflows/multiapp/MultiAppWorkflowClient.java @@ -14,7 +14,7 @@ package io.dapr.examples.workflows.multiapp; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import java.util.concurrent.TimeoutException; @@ -48,8 +48,8 @@ public static void main(String[] args) { // Wait for the workflow to complete System.out.println("Waiting for workflow completion..."); - WorkflowInstanceStatus workflowInstanceStatus = - client.waitForInstanceCompletion(instanceId, null, true); + WorkflowState workflowInstanceStatus = + client.waitForWorkflowCompletion(instanceId, null, true); // Get the result String result = workflowInstanceStatus.readOutputAs(String.class); diff --git a/examples/src/main/java/io/dapr/examples/workflows/suspendresume/DemoSuspendResumeClient.java b/examples/src/main/java/io/dapr/examples/workflows/suspendresume/DemoSuspendResumeClient.java index 5b94b5fa5..64019fa12 100644 --- a/examples/src/main/java/io/dapr/examples/workflows/suspendresume/DemoSuspendResumeClient.java +++ b/examples/src/main/java/io/dapr/examples/workflows/suspendresume/DemoSuspendResumeClient.java @@ -17,7 +17,7 @@ import io.dapr.examples.workflows.utils.PropertyUtils; import io.dapr.examples.workflows.utils.RetryUtils; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import java.time.Duration; import java.util.concurrent.TimeoutException; @@ -38,21 +38,21 @@ public static void main(String[] args) { System.out.printf("Suspending Workflow Instance: %s%n", instanceId ); client.suspendWorkflow(instanceId, "suspending workflow instance."); - WorkflowInstanceStatus instanceState = client.getInstanceState(instanceId, false); + WorkflowState instanceState = client.getWorkflowState(instanceId, false); assert instanceState != null; System.out.printf("Workflow Instance Status: %s%n", instanceState.getRuntimeStatus().name() ); System.out.printf("Let's resume the Workflow Instance before sending the external event: %s%n", instanceId ); client.resumeWorkflow(instanceId, "resuming workflow instance."); - instanceState = client.getInstanceState(instanceId, false); + instanceState = client.getWorkflowState(instanceId, false); assert instanceState != null; System.out.printf("Workflow Instance Status: %s%n", instanceState.getRuntimeStatus().name() ); System.out.printf("Now that the instance is RUNNING again, lets send the external event. %n"); client.raiseEvent(instanceId, "Approval", true); - client.waitForInstanceCompletion(instanceId, null, true); + client.waitForWorkflowCompletion(instanceId, null, true); System.out.printf("workflow instance with ID: %s completed.", instanceId); } catch (TimeoutException | InterruptedException e) { diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/DaprWorkflowsIT.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/DaprWorkflowsIT.java index db531d514..fe89a4326 100644 --- a/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/DaprWorkflowsIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/DaprWorkflowsIT.java @@ -20,7 +20,7 @@ import io.dapr.testcontainers.DaprContainer; import io.dapr.testcontainers.DaprLogLevel; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import io.dapr.workflows.client.WorkflowRuntimeStatus; import io.dapr.workflows.runtime.WorkflowRuntime; import io.dapr.workflows.runtime.WorkflowRuntimeBuilder; @@ -104,11 +104,11 @@ public void testWorkflows() throws Exception { TestWorkflowPayload payload = new TestWorkflowPayload(new ArrayList<>()); String instanceId = workflowClient.scheduleNewWorkflow(TestWorkflow.class, payload); - workflowClient.waitForInstanceStart(instanceId, Duration.ofSeconds(10), false); + workflowClient.waitForWorkflowStart(instanceId, Duration.ofSeconds(10), false); workflowClient.raiseEvent(instanceId, "MoveForward", payload); Duration timeout = Duration.ofSeconds(10); - WorkflowInstanceStatus workflowStatus = workflowClient.waitForInstanceCompletion(instanceId, timeout, true); + WorkflowState workflowStatus = workflowClient.waitForWorkflowCompletion(instanceId, timeout, true); assertNotNull(workflowStatus); @@ -124,25 +124,25 @@ public void testWorkflows() throws Exception { public void testSuspendAndResumeWorkflows() throws Exception { TestWorkflowPayload payload = new TestWorkflowPayload(new ArrayList<>()); String instanceId = workflowClient.scheduleNewWorkflow(TestWorkflow.class, payload); - workflowClient.waitForInstanceStart(instanceId, Duration.ofSeconds(10), false); + workflowClient.waitForWorkflowStart(instanceId, Duration.ofSeconds(10), false); workflowClient.suspendWorkflow(instanceId, "testing suspend."); - WorkflowInstanceStatus instanceState = workflowClient.getInstanceState(instanceId, false); + WorkflowState instanceState = workflowClient.getWorkflowState(instanceId, false); assertNotNull(instanceState); assertEquals(WorkflowRuntimeStatus.SUSPENDED, instanceState.getRuntimeStatus()); workflowClient.resumeWorkflow(instanceId, "testing resume"); - instanceState = workflowClient.getInstanceState(instanceId, false); + instanceState = workflowClient.getWorkflowState(instanceId, false); assertNotNull(instanceState); assertEquals(WorkflowRuntimeStatus.RUNNING, instanceState.getRuntimeStatus()); workflowClient.raiseEvent(instanceId, "MoveForward", payload); Duration timeout = Duration.ofSeconds(10); - instanceState = workflowClient.waitForInstanceCompletion(instanceId, timeout, true); + instanceState = workflowClient.waitForWorkflowCompletion(instanceId, timeout, true); assertNotNull(instanceState); assertEquals(WorkflowRuntimeStatus.COMPLETED, instanceState.getRuntimeStatus()); @@ -154,10 +154,10 @@ public void testNamedActivitiesWorkflows() throws Exception { TestWorkflowPayload payload = new TestWorkflowPayload(new ArrayList<>()); String instanceId = workflowClient.scheduleNewWorkflow(TestNamedActivitiesWorkflow.class, payload); - workflowClient.waitForInstanceStart(instanceId, Duration.ofSeconds(10), false); + workflowClient.waitForWorkflowStart(instanceId, Duration.ofSeconds(10), false); Duration timeout = Duration.ofSeconds(10); - WorkflowInstanceStatus workflowStatus = workflowClient.waitForInstanceCompletion(instanceId, timeout, true); + WorkflowState workflowStatus = workflowClient.waitForWorkflowCompletion(instanceId, timeout, true); assertNotNull(workflowStatus); @@ -178,10 +178,10 @@ public void testExecutionKeyWorkflows() throws Exception { TestWorkflowPayload payload = new TestWorkflowPayload(new ArrayList<>()); String instanceId = workflowClient.scheduleNewWorkflow(TestExecutionKeysWorkflow.class, payload); - workflowClient.waitForInstanceStart(instanceId, Duration.ofSeconds(100), false); + workflowClient.waitForWorkflowStart(instanceId, Duration.ofSeconds(100), false); Duration timeout = Duration.ofSeconds(1000); - WorkflowInstanceStatus workflowStatus = workflowClient.waitForInstanceCompletion(instanceId, timeout, true); + WorkflowState workflowStatus = workflowClient.waitForWorkflowCompletion(instanceId, timeout, true); assertNotNull(workflowStatus); diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/multiapp/WorkflowsMultiAppCallActivityIT.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/multiapp/WorkflowsMultiAppCallActivityIT.java index a36a77c3e..b1b0f123e 100644 --- a/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/multiapp/WorkflowsMultiAppCallActivityIT.java +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/workflows/multiapp/WorkflowsMultiAppCallActivityIT.java @@ -20,10 +20,9 @@ import io.dapr.testcontainers.DaprPlacementContainer; import io.dapr.testcontainers.DaprSchedulerContainer; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import io.dapr.workflows.client.WorkflowRuntimeStatus; import io.dapr.config.Properties; -import net.bytebuddy.utility.dispatcher.JavaDispatcher; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.testcontainers.containers.Network; @@ -176,9 +175,9 @@ public void testMultiAppWorkflow() throws Exception { try { String instanceId = workflowClient.scheduleNewWorkflow(MultiAppWorkflow.class, input); assertNotNull(instanceId, "Workflow instance ID should not be null"); - workflowClient.waitForInstanceStart(instanceId, Duration.ofSeconds(30), false); + workflowClient.waitForWorkflowStart(instanceId, Duration.ofSeconds(30), false); - WorkflowInstanceStatus workflowStatus = workflowClient.waitForInstanceCompletion(instanceId, null, true); + WorkflowState workflowStatus = workflowClient.waitForWorkflowCompletion(instanceId, null, true); assertNotNull(workflowStatus, "Workflow status should not be null"); assertEquals(WorkflowRuntimeStatus.COMPLETED, workflowStatus.getRuntimeStatus(), "Workflow should complete successfully"); diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/client/DaprWorkflowClient.java b/sdk-workflows/src/main/java/io/dapr/workflows/client/DaprWorkflowClient.java index d8b94edbe..79725c020 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/client/DaprWorkflowClient.java +++ b/sdk-workflows/src/main/java/io/dapr/workflows/client/DaprWorkflowClient.java @@ -23,6 +23,7 @@ import io.dapr.workflows.Workflow; import io.dapr.workflows.internal.ApiTokenClientInterceptor; import io.dapr.workflows.runtime.DefaultWorkflowInstanceStatus; +import io.dapr.workflows.runtime.DefaultWorkflowState; import io.grpc.ClientInterceptor; import io.grpc.ManagedChannel; @@ -116,8 +117,8 @@ public String scheduleNewWorkflow(Class clazz, Object in /** * Schedules a new workflow with a specified set of options for execution. * - * @param any Workflow type - * @param clazz Class extending Workflow to start an instance of. + * @param any Workflow type + * @param clazz Class extending Workflow to start an instance of. * @param options the options for the new workflow, including input, instance ID, etc. * @return the instanceId parameter value. */ @@ -165,14 +166,31 @@ public void terminateWorkflow(String workflowInstanceId, @Nullable Object output * @param getInputsAndOutputs true to fetch the workflow instance's * inputs, outputs, and custom status, or false to omit them * @return a metadata record that describes the workflow instance and it execution status, or a default instance + * @deprecated Use {@link #getWorkflowState(String, boolean)} instead. */ @Nullable + @Deprecated(forRemoval = true) public WorkflowInstanceStatus getInstanceState(String instanceId, boolean getInputsAndOutputs) { OrchestrationMetadata metadata = this.innerClient.getInstanceMetadata(instanceId, getInputsAndOutputs); return metadata == null ? null : new DefaultWorkflowInstanceStatus(metadata); } + /** + * Fetches workflow instance metadata from the configured durable store. + * + * @param instanceId the unique ID of the workflow instance to fetch + * @param getInputsAndOutputs true to fetch the workflow instance's + * inputs, outputs, and custom status, or false to omit them + * @return a metadata record that describes the workflow instance and it execution status, or a default instance + */ + @Nullable + public WorkflowState getWorkflowState(String instanceId, boolean getInputsAndOutputs) { + OrchestrationMetadata metadata = this.innerClient.getInstanceMetadata(instanceId, getInputsAndOutputs); + + return metadata == null ? null : new DefaultWorkflowState(metadata); + } + /** * Waits for an workflow to start running and returns an * {@link WorkflowInstanceStatus} object that contains metadata about the started @@ -189,7 +207,9 @@ public WorkflowInstanceStatus getInstanceState(String instanceId, boolean getInp * inputs, outputs, and custom status, or false to omit them * @return the workflow instance metadata or null if no such instance is found * @throws TimeoutException when the workflow instance is not started within the specified amount of time + * @deprecated Use {@link #waitForWorkflowStart(String, Duration, boolean)} instead. */ + @Deprecated(forRemoval = true) @Nullable public WorkflowInstanceStatus waitForInstanceStart(String instanceId, Duration timeout, boolean getInputsAndOutputs) throws TimeoutException { @@ -199,6 +219,33 @@ public WorkflowInstanceStatus waitForInstanceStart(String instanceId, Duration t return metadata == null ? null : new DefaultWorkflowInstanceStatus(metadata); } + + /** + * Waits for a workflow to start running and returns an + * {@link WorkflowState} object that contains metadata about the started + * instance and optionally its input, output, and custom status payloads. + * + *

A "started" workflow instance is any instance not in the Pending state. + * + *

If an workflow instance is already running when this method is called, + * the method will return immediately. + * + * @param instanceId the unique ID of the workflow instance to wait for + * @param timeout the amount of time to wait for the workflow instance to start + * @param getInputsAndOutputs true to fetch the workflow instance's + * inputs, outputs, and custom status, or false to omit them + * @return the workflow instance metadata or null if no such instance is found + * @throws TimeoutException when the workflow instance is not started within the specified amount of time + */ + @Nullable + public WorkflowState waitForWorkflowStart(String instanceId, Duration timeout, boolean getInputsAndOutputs) + throws TimeoutException { + + OrchestrationMetadata metadata = this.innerClient.waitForInstanceStart(instanceId, timeout, getInputsAndOutputs); + + return metadata == null ? null : new DefaultWorkflowState(metadata); + } + /** * Waits for an workflow to complete and returns an {@link WorkflowInstanceStatus} object that contains * metadata about the completed instance. @@ -217,16 +264,47 @@ public WorkflowInstanceStatus waitForInstanceStart(String instanceId, Duration t * status, or false to omit them * @return the workflow instance metadata or null if no such instance is found * @throws TimeoutException when the workflow instance is not completed within the specified amount of time + * @deprecated Use {@link #waitForWorkflowCompletion(String, Duration, boolean)} instead. */ @Nullable + @Deprecated(forRemoval = true) public WorkflowInstanceStatus waitForInstanceCompletion(String instanceId, Duration timeout, - boolean getInputsAndOutputs) throws TimeoutException { + boolean getInputsAndOutputs) throws TimeoutException { OrchestrationMetadata metadata = this.innerClient.waitForInstanceCompletion(instanceId, timeout, getInputsAndOutputs); return metadata == null ? null : new DefaultWorkflowInstanceStatus(metadata); } + + /** + * Waits for an workflow to complete and returns an {@link WorkflowState} object that contains + * metadata about the completed instance. + * + *

A "completed" workflow instance is any instance in one of the terminal states. For example, the + * Completed, Failed, or Terminated states. + * + *

Workflows are long-running and could take hours, days, or months before completing. + * Workflows can also be eternal, in which case they'll never complete unless terminated. + * In such cases, this call may block indefinitely, so care must be taken to ensure appropriate timeouts are used. + * If an workflow instance is already complete when this method is called, the method will return immediately. + * + * @param instanceId the unique ID of the workflow instance to wait for + * @param timeout the amount of time to wait for the workflow instance to complete + * @param getInputsAndOutputs true to fetch the workflow instance's inputs, outputs, and custom + * status, or false to omit them + * @return the workflow instance metadata or null if no such instance is found + * @throws TimeoutException when the workflow instance is not completed within the specified amount of time + */ + @Nullable + public WorkflowState waitForWorkflowCompletion(String instanceId, Duration timeout, + boolean getInputsAndOutputs) throws TimeoutException { + + OrchestrationMetadata metadata = this.innerClient.waitForInstanceCompletion(instanceId, timeout, + getInputsAndOutputs); + return metadata == null ? null : new DefaultWorkflowState(metadata); + } + /** * Sends an event notification message to awaiting workflow instance. * @@ -243,7 +321,9 @@ public void raiseEvent(String workflowInstanceId, String eventName, Object event * * @param workflowInstanceId The unique ID of the workflow instance to purge. * @return Return true if the workflow state was found and purged successfully otherwise false. + * @deprecated Use {@link #purgeWorkflow(String)} instead. */ + @Deprecated(forRemoval = true) public boolean purgeInstance(String workflowInstanceId) { PurgeResult result = this.innerClient.purgeInstance(workflowInstanceId); @@ -254,6 +334,22 @@ public boolean purgeInstance(String workflowInstanceId) { return false; } + /** + * Purges workflow instance state from the workflow state store. + * + * @param workflowInstanceId The unique ID of the workflow instance to purge. + * @return Return true if the workflow state was found and purged successfully otherwise false. + */ + public boolean purgeWorkflow(String workflowInstanceId) { + PurgeResult result = this.innerClient.purgeInstance(workflowInstanceId); + + if (result != null) { + return result.getDeletedInstanceCount() > 0; + } + + return false; + } + /** * Closes the inner DurableTask client and shutdown the GRPC channel. */ diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/client/WorkflowInstanceStatus.java b/sdk-workflows/src/main/java/io/dapr/workflows/client/WorkflowInstanceStatus.java index de4d3bdd3..bdcd0087f 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/client/WorkflowInstanceStatus.java +++ b/sdk-workflows/src/main/java/io/dapr/workflows/client/WorkflowInstanceStatus.java @@ -20,7 +20,9 @@ /** * Represents a snapshot of a workflow instance's current state, including * metadata. + * @deprecated Use {@link WorkflowState} instead. */ +@Deprecated(forRemoval = true) public interface WorkflowInstanceStatus { /** diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/client/WorkflowState.java b/sdk-workflows/src/main/java/io/dapr/workflows/client/WorkflowState.java new file mode 100644 index 000000000..282d1d73e --- /dev/null +++ b/sdk-workflows/src/main/java/io/dapr/workflows/client/WorkflowState.java @@ -0,0 +1,142 @@ +/* + * Copyright 2023 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.workflows.client; + +import javax.annotation.Nullable; + +import java.time.Instant; + +/** + * Represents a snapshot of a workflow instance's current state, including + * metadata. + */ +public interface WorkflowState { + + /** + * Gets the name of the workflow. + * + * @return the name of the workflow + */ + String getName(); + + /** + * Gets the unique ID of the workflow instance. + * + * @return the unique ID of the workflow instance + */ + String getWorkflowId(); + + /** + * Gets the current runtime status of the workflow instance at the time this + * object was fetched. + * + * @return the current runtime status of the workflow instance at the time this object was fetched + */ + WorkflowRuntimeStatus getRuntimeStatus(); + + /** + * Gets the workflow instance's creation time in UTC. + * + * @return the workflow instance's creation time in UTC + */ + Instant getCreatedAt(); + + /** + * Gets the workflow instance's last updated time in UTC. + * + * @return the workflow instance's last updated time in UTC + */ + Instant getLastUpdatedAt(); + + /** + * Gets the workflow instance's serialized input, if any, as a string value. + * + * @return the workflow instance's serialized input or {@code null} + */ + String getSerializedInput(); + + /** + * Gets the workflow instance's serialized output, if any, as a string value. + * + * @return the workflow instance's serialized output or {@code null} + */ + String getSerializedOutput(); + + /** + * Gets the failure details, if any, for the failed workflow instance. + * + *

This method returns data only if the workflow is in the + * {@link WorkflowFailureDetails} failureDetails, + * and only if this instance metadata was fetched with the option to include + * output data. + * + * @return the failure details of the failed workflow instance or {@code null} + */ + @Nullable + WorkflowFailureDetails getFailureDetails(); + + /** + * Gets a value indicating whether the workflow instance was running at the time + * this object was fetched. + * + * @return {@code true} if the workflow existed and was in a running state otherwise {@code false} + */ + boolean isRunning(); + + /** + * Gets a value indicating whether the workflow instance was completed at the + * time this object was fetched. + * + *

A workflow instance is considered completed when its runtime status value is + * {@link WorkflowRuntimeStatus#COMPLETED}, + * {@link WorkflowRuntimeStatus#FAILED}, or + * {@link WorkflowRuntimeStatus#TERMINATED}. + * + * @return {@code true} if the workflow was in a terminal state; otherwise {@code false} + */ + boolean isCompleted(); + + /** + * Deserializes the workflow's input into an object of the specified type. + * + *

Deserialization is performed using the DataConverter that was + * configured on the DurableTaskClient object that created this workflow + * metadata object. + * + * @param type the class associated with the type to deserialize the input data + * into + * @param the type to deserialize the input data into + * @return the deserialized input value + * @throws IllegalStateException if the metadata was fetched without the option + * to read inputs and outputs + */ + T readInputAs(Class type); + + /** + * Deserializes the workflow's output into an object of the specified type. + * + *

Deserialization is performed using the DataConverter that was + * configured on the DurableTaskClient + * object that created this workflow metadata object. + * + * @param type the class associated with the type to deserialize the output data + * into + * @param the type to deserialize the output data into + * @return the deserialized input value + * @throws IllegalStateException if the metadata was fetched without the option + * to read inputs and outputs + */ + T readOutputAs(Class type); + +} diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowInstanceStatus.java b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowInstanceStatus.java index 392357bc3..2c63dc945 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowInstanceStatus.java +++ b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowInstanceStatus.java @@ -27,7 +27,9 @@ /** * Represents a snapshot of a workflow instance's current state, including * metadata. + * @deprecated Use {@link DefaultWorkflowState} instead. */ +@Deprecated(forRemoval = true) public class DefaultWorkflowInstanceStatus implements WorkflowInstanceStatus { private final OrchestrationMetadata orchestrationMetadata; diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowState.java b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowState.java new file mode 100644 index 000000000..78420d4c8 --- /dev/null +++ b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowState.java @@ -0,0 +1,210 @@ +/* + * Copyright 2023 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.workflows.runtime; + +import io.dapr.durabletask.FailureDetails; +import io.dapr.durabletask.OrchestrationMetadata; +import io.dapr.durabletask.OrchestrationRuntimeStatus; +import io.dapr.workflows.client.WorkflowFailureDetails; +import io.dapr.workflows.client.WorkflowRuntimeStatus; +import io.dapr.workflows.client.WorkflowState; + +import javax.annotation.Nullable; + +import java.time.Instant; + +/** + * Represents a snapshot of a workflow instance's current state, including + * metadata. + */ +public class DefaultWorkflowState implements WorkflowState { + + private final OrchestrationMetadata orchestrationMetadata; + + @Nullable + private final WorkflowFailureDetails failureDetails; + + /** + * Class constructor. + * + * @param orchestrationMetadata Durable task orchestration metadata + */ + public DefaultWorkflowState(OrchestrationMetadata orchestrationMetadata) { + if (orchestrationMetadata == null) { + throw new IllegalArgumentException("OrchestrationMetadata cannot be null"); + } + this.orchestrationMetadata = orchestrationMetadata; + + FailureDetails details = orchestrationMetadata.getFailureDetails(); + + if (details != null) { + this.failureDetails = new DefaultWorkflowFailureDetails(details); + } else { + this.failureDetails = null; + } + } + + /** + * Gets the name of the workflow. + * + * @return the name of the workflow + */ + public String getName() { + return orchestrationMetadata.getName(); + } + + /** + * Gets the unique ID of the workflow instance. + * + * @return the unique ID of the workflow instance + */ + public String getWorkflowId() { + return orchestrationMetadata.getInstanceId(); + } + + /** + * Gets the current runtime status of the workflow instance at the time this + * object was fetched. + * + * @return the current runtime status of the workflow instance at the time this object was fetched + */ + public WorkflowRuntimeStatus getRuntimeStatus() { + OrchestrationRuntimeStatus status = orchestrationMetadata.getRuntimeStatus(); + + return WorkflowRuntimeStatusConverter.fromOrchestrationRuntimeStatus(status); + } + + /** + * Gets the workflow instance's creation time in UTC. + * + * @return the workflow instance's creation time in UTC + */ + public Instant getCreatedAt() { + return orchestrationMetadata.getCreatedAt(); + } + + /** + * Gets the workflow instance's last updated time in UTC. + * + * @return the workflow instance's last updated time in UTC + */ + public Instant getLastUpdatedAt() { + return orchestrationMetadata.getLastUpdatedAt(); + } + + /** + * Gets the workflow instance's serialized input, if any, as a string value. + * + * @return the workflow instance's serialized input or {@code null} + */ + public String getSerializedInput() { + return orchestrationMetadata.getSerializedInput(); + } + + /** + * Gets the workflow instance's serialized output, if any, as a string value. + * + * @return the workflow instance's serialized output or {@code null} + */ + public String getSerializedOutput() { + return orchestrationMetadata.getSerializedOutput(); + } + + /** + * Gets the failure details, if any, for the failed workflow instance. + * + *

This method returns data only if the workflow is in the + * {@link OrchestrationRuntimeStatus#FAILED} state, + * and only if this instance metadata was fetched with the option to include + * output data. + * + * @return the failure details of the failed workflow instance or {@code null} + */ + @Nullable + public WorkflowFailureDetails getFailureDetails() { + return this.failureDetails; + } + + /** + * Gets a value indicating whether the workflow instance was running at the time + * this object was fetched. + * + * @return {@code true} if the workflow existed and was in a running state otherwise {@code false} + */ + public boolean isRunning() { + return orchestrationMetadata.isRunning(); + } + + /** + * Gets a value indicating whether the workflow instance was completed at the + * time this object was fetched. + * + *

A workflow instance is considered completed when its runtime status value is + * {@link WorkflowRuntimeStatus#COMPLETED}, + * {@link WorkflowRuntimeStatus#FAILED}, or + * {@link WorkflowRuntimeStatus#TERMINATED}. + * + * @return {@code true} if the workflow was in a terminal state; otherwise {@code false} + */ + public boolean isCompleted() { + return orchestrationMetadata.isCompleted(); + } + + /** + * Deserializes the workflow's input into an object of the specified type. + * + *

Deserialization is performed using the DataConverter that was + * configured on the DurableTaskClient object that created this workflow + * metadata object. + * + * @param type the class associated with the type to deserialize the input data + * into + * @param the type to deserialize the input data into + * @return the deserialized input value + * @throws IllegalStateException if the metadata was fetched without the option + * to read inputs and outputs + */ + public T readInputAs(Class type) { + return orchestrationMetadata.readInputAs(type); + } + + /** + * Deserializes the workflow's output into an object of the specified type. + * + *

Deserialization is performed using the DataConverter that was + * configured on the DurableTaskClient + * object that created this workflow metadata object. + * + * @param type the class associated with the type to deserialize the output data + * into + * @param the type to deserialize the output data into + * @return the deserialized input value + * @throws IllegalStateException if the metadata was fetched without the option + * to read inputs and outputs + */ + public T readOutputAs(Class type) { + return orchestrationMetadata.readOutputAs(type); + } + + /** + * Generates a user-friendly string representation of the current metadata + * object. + * + * @return a user-friendly string representation of the current metadata object + */ + public String toString() { + return orchestrationMetadata.toString(); + } + +} diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/client/DaprWorkflowClientTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/client/DaprWorkflowClientTest.java index 55f7c9fdd..71fa93f0d 100644 --- a/sdk-workflows/src/test/java/io/dapr/workflows/client/DaprWorkflowClientTest.java +++ b/sdk-workflows/src/test/java/io/dapr/workflows/client/DaprWorkflowClientTest.java @@ -156,12 +156,12 @@ public void getInstanceMetadata() { when(mockInnerClient.getInstanceMetadata(instanceId, true)).thenReturn(expectedMetadata); // Act - WorkflowInstanceStatus metadata = client.getInstanceState(instanceId, true); + WorkflowState metadata = client.getWorkflowState(instanceId, true); // Assert verify(mockInnerClient, times(1)).getInstanceMetadata(instanceId, true); assertNotEquals(metadata, null); - assertEquals(metadata.getInstanceId(), expectedMetadata.getInstanceId()); + assertEquals(metadata.getWorkflowId(), expectedMetadata.getInstanceId()); assertEquals(metadata.getName(), expectedMetadata.getName()); assertEquals(metadata.isRunning(), expectedMetadata.isRunning()); assertEquals(metadata.isCompleted(), expectedMetadata.isCompleted()); @@ -179,12 +179,12 @@ public void waitForInstanceStart() throws TimeoutException { when(mockInnerClient.waitForInstanceStart(instanceId, timeout, true)).thenReturn(expectedMetadata); // Act - WorkflowInstanceStatus result = client.waitForInstanceStart(instanceId, timeout, true); + WorkflowState result = client.waitForWorkflowStart(instanceId, timeout, true); // Assert verify(mockInnerClient, times(1)).waitForInstanceStart(instanceId, timeout, true); assertNotEquals(result, null); - assertEquals(result.getInstanceId(), expectedMetadata.getInstanceId()); + assertEquals(result.getWorkflowId(), expectedMetadata.getInstanceId()); } @Test @@ -199,12 +199,12 @@ public void waitForInstanceCompletion() throws TimeoutException { when(mockInnerClient.waitForInstanceCompletion(instanceId, timeout, true)).thenReturn(expectedMetadata); // Act - WorkflowInstanceStatus result = client.waitForInstanceCompletion(instanceId, timeout, true); + WorkflowState result = client.waitForWorkflowCompletion(instanceId, timeout, true); // Assert verify(mockInnerClient, times(1)).waitForInstanceCompletion(instanceId, timeout, true); assertNotEquals(result, null); - assertEquals(result.getInstanceId(), expectedMetadata.getInstanceId()); + assertEquals(result.getWorkflowId(), expectedMetadata.getInstanceId()); } @Test @@ -231,7 +231,7 @@ public void suspendResumeInstance() { @Test public void purgeInstance() { String expectedArgument = "TestWorkflowInstanceId"; - client.purgeInstance(expectedArgument); + client.purgeWorkflow(expectedArgument); verify(mockInnerClient, times(1)).purgeInstance(expectedArgument); } diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/client/WorkflowStateTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/client/WorkflowStateTest.java new file mode 100644 index 000000000..dc3db11b0 --- /dev/null +++ b/sdk-workflows/src/test/java/io/dapr/workflows/client/WorkflowStateTest.java @@ -0,0 +1,213 @@ +/* + * Copyright 2023 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.workflows.client; + +import io.dapr.durabletask.FailureDetails; +import io.dapr.durabletask.OrchestrationMetadata; +import io.dapr.durabletask.OrchestrationRuntimeStatus; +import io.dapr.workflows.runtime.DefaultWorkflowState; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorkflowStateTest { + + private OrchestrationMetadata mockOrchestrationMetadata; + private WorkflowState workflowMetadata; + + @BeforeEach + public void setUp() { + mockOrchestrationMetadata = mock(OrchestrationMetadata.class); + workflowMetadata = new DefaultWorkflowState(mockOrchestrationMetadata); + } + + @Test + public void getInstanceId() { + String expected = "instanceId"; + + when(mockOrchestrationMetadata.getInstanceId()).thenReturn(expected); + + String result = workflowMetadata.getWorkflowId(); + + verify(mockOrchestrationMetadata, times(1)).getInstanceId(); + assertEquals(expected, result); + } + + @Test + public void getName() { + String expected = "WorkflowName"; + + when(mockOrchestrationMetadata.getName()).thenReturn(expected); + + String result = workflowMetadata.getName(); + + verify(mockOrchestrationMetadata, times(1)).getName(); + assertEquals(expected, result); + } + + @Test + public void getCreatedAt() { + Instant expected = Instant.now(); + when(mockOrchestrationMetadata.getCreatedAt()).thenReturn(expected); + + Instant result = workflowMetadata.getCreatedAt(); + + verify(mockOrchestrationMetadata, times(1)).getCreatedAt(); + assertEquals(expected, result); + } + + @Test + public void getLastUpdatedAt() { + Instant expected = Instant.now(); + + when(mockOrchestrationMetadata.getLastUpdatedAt()).thenReturn(expected); + + Instant result = workflowMetadata.getLastUpdatedAt(); + + verify(mockOrchestrationMetadata, times(1)).getLastUpdatedAt(); + assertEquals(expected, result); + } + + @Test + public void getFailureDetails() { + FailureDetails mockFailureDetails = mock(FailureDetails.class); + + when(mockFailureDetails.getErrorType()).thenReturn("errorType"); + when(mockFailureDetails.getErrorMessage()).thenReturn("errorMessage"); + when(mockFailureDetails.getStackTrace()).thenReturn("stackTrace"); + + OrchestrationMetadata orchestrationMetadata = mock(OrchestrationMetadata.class); + when(orchestrationMetadata.getFailureDetails()).thenReturn(mockFailureDetails); + + WorkflowState metadata = new DefaultWorkflowState(orchestrationMetadata); + WorkflowFailureDetails result = metadata.getFailureDetails(); + + verify(orchestrationMetadata, times(1)).getFailureDetails(); + assertEquals(mockFailureDetails.getErrorType(), result.getErrorType()); + assertEquals(mockFailureDetails.getErrorMessage(), result.getErrorMessage()); + assertEquals(mockFailureDetails.getStackTrace(), result.getStackTrace()); + } + + @Test + public void getRuntimeStatus() { + WorkflowRuntimeStatus expected = WorkflowRuntimeStatus.RUNNING; + + when(mockOrchestrationMetadata.getRuntimeStatus()).thenReturn(OrchestrationRuntimeStatus.RUNNING); + + WorkflowRuntimeStatus result = workflowMetadata.getRuntimeStatus(); + + verify(mockOrchestrationMetadata, times(1)).getRuntimeStatus(); + assertEquals(expected, result); + } + + @Test + public void isRunning() { + boolean expected = true; + + when(mockOrchestrationMetadata.isRunning()).thenReturn(expected); + + boolean result = workflowMetadata.isRunning(); + + verify(mockOrchestrationMetadata, times(1)).isRunning(); + assertEquals(expected, result); + } + + @Test + public void isCompleted() { + boolean expected = true; + + when(mockOrchestrationMetadata.isCompleted()).thenReturn(expected); + + boolean result = workflowMetadata.isCompleted(); + + verify(mockOrchestrationMetadata, times(1)).isCompleted(); + assertEquals(expected, result); + } + + @Test + public void getSerializedInput() { + String expected = "{input: \"test\"}"; + + when(mockOrchestrationMetadata.getSerializedInput()).thenReturn(expected); + + String result = workflowMetadata.getSerializedInput(); + + verify(mockOrchestrationMetadata, times(1)).getSerializedInput(); + assertEquals(expected, result); + } + + @Test + public void getSerializedOutput() { + String expected = "{output: \"test\"}"; + + when(mockOrchestrationMetadata.getSerializedOutput()).thenReturn(expected); + + String result = workflowMetadata.getSerializedOutput(); + + verify(mockOrchestrationMetadata, times(1)).getSerializedOutput(); + assertEquals(expected, result); + } + + @Test + public void readInputAs() { + String expected = "[{property: \"test input\"}}]"; + + when(mockOrchestrationMetadata.readInputAs(String.class)).thenReturn(expected); + + String result = workflowMetadata.readInputAs(String.class); + + verify(mockOrchestrationMetadata, times(1)).readInputAs(String.class); + assertEquals(expected, result); + } + + @Test + public void readOutputAs() { + String expected = "[{property: \"test output\"}}]"; + + when(mockOrchestrationMetadata.readOutputAs(String.class)).thenReturn(expected); + + String result = workflowMetadata.readOutputAs(String.class); + + verify(mockOrchestrationMetadata, times(1)).readOutputAs(String.class); + assertEquals(expected, result); + } + + @Test + public void testToString() { + String expected = "string value"; + + when(mockOrchestrationMetadata.toString()).thenReturn(expected); + + String result = workflowMetadata.toString(); + + assertEquals(expected, result); + } + + @Test + public void testWithNoMetadata() { + String message = Assertions.assertThrows(IllegalArgumentException.class, () -> { + DefaultWorkflowState workflowState = new DefaultWorkflowState(null); + }).getMessage(); + + Assertions.assertTrue(message.contains("OrchestrationMetadata cannot be null")); + } +} diff --git a/spring-boot-examples/workflows/multi-app/orchestrator/src/main/java/io/dapr/springboot/examples/orchestrator/CustomersRestController.java b/spring-boot-examples/workflows/multi-app/orchestrator/src/main/java/io/dapr/springboot/examples/orchestrator/CustomersRestController.java index 46277ada9..817405d07 100644 --- a/spring-boot-examples/workflows/multi-app/orchestrator/src/main/java/io/dapr/springboot/examples/orchestrator/CustomersRestController.java +++ b/spring-boot-examples/workflows/multi-app/orchestrator/src/main/java/io/dapr/springboot/examples/orchestrator/CustomersRestController.java @@ -13,9 +13,8 @@ package io.dapr.springboot.examples.orchestrator; -import io.dapr.spring.workflows.config.EnableDaprWorkflows; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -84,7 +83,7 @@ public String getCustomerStatus(@RequestBody Customer customer) { if (workflowIdForCustomer == null || workflowIdForCustomer.isEmpty()) { return "N/A"; } - WorkflowInstanceStatus instanceState = daprWorkflowClient.getInstanceState(workflowIdForCustomer, true); + WorkflowState instanceState = daprWorkflowClient.getWorkflowState(workflowIdForCustomer, true); assert instanceState != null; return "Workflow for Customer: " + customer.getCustomerName() + " is " + instanceState.getRuntimeStatus().name(); } @@ -101,7 +100,7 @@ public Customer getCustomerOutput(@RequestBody Customer customer) { if (workflowIdForCustomer == null || workflowIdForCustomer.isEmpty()) { return null; } - WorkflowInstanceStatus instanceState = daprWorkflowClient.getInstanceState(workflowIdForCustomer, true); + WorkflowState instanceState = daprWorkflowClient.getWorkflowState(workflowIdForCustomer, true); assert instanceState != null; return instanceState.readOutputAs(Customer.class); } diff --git a/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/OrchestratorAppIT.java b/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/OrchestratorAppIT.java index 5113256d9..772ec82c0 100644 --- a/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/OrchestratorAppIT.java +++ b/spring-boot-examples/workflows/multi-app/orchestrator/src/test/java/io/dapr/springboot/examples/orchestrator/OrchestratorAppIT.java @@ -24,8 +24,6 @@ import static io.restassured.RestAssured.given; import static org.awaitility.Awaitility.await; -import static org.hamcrest.CoreMatchers.is; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest(classes = {TestOrchestratorApplication.class, DaprTestContainersConfig.class, CustomersRestController.class}, diff --git a/spring-boot-examples/workflows/patterns/src/main/java/io/dapr/springboot/examples/wfp/WorkflowPatternsRestController.java b/spring-boot-examples/workflows/patterns/src/main/java/io/dapr/springboot/examples/wfp/WorkflowPatternsRestController.java index f1f8856d1..4bb1b0a24 100644 --- a/spring-boot-examples/workflows/patterns/src/main/java/io/dapr/springboot/examples/wfp/WorkflowPatternsRestController.java +++ b/spring-boot-examples/workflows/patterns/src/main/java/io/dapr/springboot/examples/wfp/WorkflowPatternsRestController.java @@ -27,7 +27,7 @@ import io.dapr.springboot.examples.wfp.timer.DurationTimerWorkflow; import io.dapr.springboot.examples.wfp.timer.ZonedDateTimeTimerWorkflow; import io.dapr.workflows.client.DaprWorkflowClient; -import io.dapr.workflows.client.WorkflowInstanceStatus; +import io.dapr.workflows.client.WorkflowState; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -67,7 +67,7 @@ public String chain() throws TimeoutException { String instanceId = daprWorkflowClient.scheduleNewWorkflow(ChainWorkflow.class); logger.info("Workflow instance " + instanceId + " started"); return daprWorkflowClient - .waitForInstanceCompletion(instanceId, Duration.ofSeconds(10), true) + .waitForWorkflowCompletion(instanceId, Duration.ofSeconds(10), true) .readOutputAs(String.class); } @@ -81,7 +81,7 @@ public String child() throws TimeoutException { String instanceId = daprWorkflowClient.scheduleNewWorkflow(ParentWorkflow.class); logger.info("Workflow instance " + instanceId + " started"); return daprWorkflowClient - .waitForInstanceCompletion(instanceId, Duration.ofSeconds(10), true) + .waitForWorkflowCompletion(instanceId, Duration.ofSeconds(10), true) .readOutputAs(String.class); } @@ -97,13 +97,13 @@ public Result fanOutIn(@RequestBody List listOfStrings) throws TimeoutEx logger.info("Workflow instance " + instanceId + " started"); // Block until the orchestration completes. Then print the final status, which includes the output. - WorkflowInstanceStatus workflowInstanceStatus = daprWorkflowClient.waitForInstanceCompletion( + WorkflowState workflowState = daprWorkflowClient.waitForWorkflowCompletion( instanceId, Duration.ofSeconds(30), true); logger.info("workflow instance with ID: %s completed with result: %s%n", instanceId, - workflowInstanceStatus.readOutputAs(Result.class)); - return workflowInstanceStatus.readOutputAs(Result.class); + workflowState.readOutputAs(Result.class)); + return workflowState.readOutputAs(Result.class); } /** @@ -124,8 +124,8 @@ public Decision externalEventContinue(@RequestParam("orderId") String orderId, @ String instanceId = ordersToApprove.get(orderId); logger.info("Workflow instance " + instanceId + " continue"); daprWorkflowClient.raiseEvent(instanceId, "Approval", decision); - WorkflowInstanceStatus workflowInstanceStatus = daprWorkflowClient - .waitForInstanceCompletion(instanceId, null, true); + WorkflowState workflowInstanceStatus = daprWorkflowClient + .waitForWorkflowCompletion(instanceId, null, true); return workflowInstanceStatus.readOutputAs(Decision.class); } @@ -137,7 +137,7 @@ public CleanUpLog continueAsNew() String instanceId = daprWorkflowClient.scheduleNewWorkflow(ContinueAsNewWorkflow.class); logger.info("Workflow instance " + instanceId + " started"); - WorkflowInstanceStatus workflowInstanceStatus = daprWorkflowClient.waitForInstanceCompletion(instanceId, null, true); + WorkflowState workflowInstanceStatus = daprWorkflowClient.waitForWorkflowCompletion(instanceId, null, true); System.out.printf("workflow instance with ID: %s completed.", instanceId); return workflowInstanceStatus.readOutputAs(CleanUpLog.class); } @@ -149,8 +149,8 @@ public Payload remoteEndpoint(@RequestBody Payload payload) String instanceId = daprWorkflowClient.scheduleNewWorkflow(RemoteEndpointWorkflow.class, payload); logger.info("Workflow instance " + instanceId + " started"); - WorkflowInstanceStatus workflowInstanceStatus = daprWorkflowClient - .waitForInstanceCompletion(instanceId, null, true); + WorkflowState workflowInstanceStatus = daprWorkflowClient + .waitForWorkflowCompletion(instanceId, null, true); System.out.printf("workflow instance with ID: %s completed.", instanceId); return workflowInstanceStatus.readOutputAs(Payload.class); } @@ -167,7 +167,7 @@ public String suspendResume(@RequestParam("orderId") String orderId) { public String suspendResumeExecuteSuspend(@RequestParam("orderId") String orderId) { String instanceId = ordersToApprove.get(orderId); daprWorkflowClient.suspendWorkflow(instanceId, "testing suspend"); - WorkflowInstanceStatus instanceState = daprWorkflowClient.getInstanceState(instanceId, false); + WorkflowState instanceState = daprWorkflowClient.getWorkflowState(instanceId, false); return instanceState.getRuntimeStatus().name(); } @@ -175,7 +175,7 @@ public String suspendResumeExecuteSuspend(@RequestParam("orderId") String orderI public String suspendResumeExecuteResume(@RequestParam("orderId") String orderId) { String instanceId = ordersToApprove.get(orderId); daprWorkflowClient.resumeWorkflow(instanceId, "testing resume"); - WorkflowInstanceStatus instanceState = daprWorkflowClient.getInstanceState(instanceId, false); + WorkflowState instanceState = daprWorkflowClient.getWorkflowState(instanceId, false); return instanceState.getRuntimeStatus().name(); } @@ -186,8 +186,8 @@ public Decision suspendResumeContinue(@RequestParam("orderId") String orderId, @ String instanceId = ordersToApprove.get(orderId); logger.info("Workflow instance " + instanceId + " continue"); daprWorkflowClient.raiseEvent(instanceId, "Approval", decision); - WorkflowInstanceStatus workflowInstanceStatus = daprWorkflowClient - .waitForInstanceCompletion(instanceId, null, true); + WorkflowState workflowInstanceStatus = daprWorkflowClient + .waitForWorkflowCompletion(instanceId, null, true); return workflowInstanceStatus.readOutputAs(Decision.class); } From 1afdaf58fbb2398b3a1389e7c2f355ee7a4d220f Mon Sep 17 00:00:00 2001 From: salaboy Date: Tue, 21 Oct 2025 10:56:10 +0100 Subject: [PATCH 4/8] use built in durable task Signed-off-by: salaboy --- durabletask-client/pom.xml | 4 ---- pom.xml | 11 ---------- sdk-workflows/pom.xml | 22 +------------------ .../runtime/DefaultWorkflowContext.java | 2 +- .../workflows/DefaultWorkflowContextTest.java | 2 +- 5 files changed, 3 insertions(+), 38 deletions(-) diff --git a/durabletask-client/pom.xml b/durabletask-client/pom.xml index 98b1f4ac2..93bed3255 100644 --- a/durabletask-client/pom.xml +++ b/durabletask-client/pom.xml @@ -68,10 +68,6 @@ org.testcontainers testcontainers - - io.dapr - durabletask-client - diff --git a/pom.xml b/pom.xml index 6567f7a82..085ae11fd 100644 --- a/pom.xml +++ b/pom.xml @@ -33,11 +33,6 @@ 11 11 true - 2.16.2 true true @@ -72,7 +67,6 @@ 5.7.0 1.7.0 3.5.12 - 1.5.10 2.2.2 2.0.9 3.11.2 @@ -482,11 +476,6 @@ reactor-core ${reactor.version} - - io.dapr - durabletask-client - ${durabletask-client.version} - com.redis testcontainers-redis diff --git a/sdk-workflows/pom.xml b/sdk-workflows/pom.xml index 3afb38c40..3dbe237e3 100644 --- a/sdk-workflows/pom.xml +++ b/sdk-workflows/pom.xml @@ -44,27 +44,7 @@ io.dapr durabletask-client - - - - com.fasterxml.jackson.core - jackson-core - - - com.fasterxml.jackson.core - jackson-databind - - - com.fasterxml.jackson.core - jackson-annotations - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 + ${project.parent.version} diff --git a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowContext.java b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowContext.java index 067850c93..4ccf73e9b 100644 --- a/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowContext.java +++ b/sdk-workflows/src/main/java/io/dapr/workflows/runtime/DefaultWorkflowContext.java @@ -246,7 +246,7 @@ public void continueAsNew(Object input, boolean preserveUnprocessedEvents) { */ @Override public UUID newUuid() { - return this.innerContext.newUUID(); + return this.innerContext.newUuid(); } private TaskOptions toTaskOptions(WorkflowTaskOptions options) { diff --git a/sdk-workflows/src/test/java/io/dapr/workflows/DefaultWorkflowContextTest.java b/sdk-workflows/src/test/java/io/dapr/workflows/DefaultWorkflowContextTest.java index b573e2611..b6ca38ecb 100644 --- a/sdk-workflows/src/test/java/io/dapr/workflows/DefaultWorkflowContextTest.java +++ b/sdk-workflows/src/test/java/io/dapr/workflows/DefaultWorkflowContextTest.java @@ -422,7 +422,7 @@ public void setCustomStatusWorkflow() { @Test public void newUuidTest() { context.newUuid(); - verify(mockInnerContext, times(1)).newUUID(); + verify(mockInnerContext, times(1)).newUuid(); } @Test From 260e6e8a3963e158c94127bf7223e6d3da0b440c Mon Sep 17 00:00:00 2001 From: salaboy Date: Tue, 21 Oct 2025 11:27:00 +0100 Subject: [PATCH 5/8] exclude jacoco rules for examples and durabletask-client Signed-off-by: salaboy --- pom.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pom.xml b/pom.xml index 085ae11fd..b04ec344e 100644 --- a/pom.xml +++ b/pom.xml @@ -590,6 +590,10 @@ check + + io/dapr/durabletask/**/* + io/dapr/springboot/examples/**/* + BUNDLE From d001e9fa5818b163b21a64b638dccbd774a87207 Mon Sep 17 00:00:00 2001 From: salaboy Date: Tue, 21 Oct 2025 12:45:51 +0100 Subject: [PATCH 6/8] increasing timeout for IT Signed-off-by: salaboy --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 00e7c3910..f00bf9371 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,7 +47,7 @@ jobs: build: name: "Build jdk:${{ matrix.java }} sb:${{ matrix.spring-boot-display-version }} exp:${{ matrix.experimental }}" runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 continue-on-error: ${{ matrix.experimental }} strategy: fail-fast: false From 0cf9d19fe197efbe59c688407a9995f306f8b009 Mon Sep 17 00:00:00 2001 From: salaboy Date: Tue, 21 Oct 2025 12:55:42 +0100 Subject: [PATCH 7/8] removing dt build from matrix Signed-off-by: salaboy --- .github/workflows/build.yml | 69 +++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f00bf9371..eb0dc00da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,6 +44,48 @@ jobs: name: report-dapr-java-sdk-actors-jdk${{ env.JDK_VER }} path: sdk-actors/target/jacoco-report/ + build-durabletask: + name: "Durable Task build & tests" + runs-on: ubuntu-latest + timeout-minutes: 30 + continue-on-error: false + env: + JDK_VER: 17 + steps: + - name: Checkout Durable Task Sidecar + uses: actions/checkout@v4 + with: + repository: dapr/durabletask-go + path: durabletask-sidecar + + # TODO: Move the sidecar into a central image repository + - name: Initialize Durable Task Sidecar + run: docker run -d --name durabletask-sidecar -p 4001:4001 --rm -i $(docker build -q ./durabletask-sidecar) + + - name: Display Durable Task Sidecar Logs + run: nohup docker logs --since=0 durabletask-sidecar > durabletask-sidecar.log 2>&1 & + + # wait for 10 seconds, so sidecar container can be fully up, this will avoid intermittent failing issues for integration tests causing by failed to connect to sidecar + - name: Wait for 10 seconds + run: sleep 10 + + - name: Integration Tests For Durable Tasks + run: ./mvnw -B -pl durabletask-client -Pintegration-tests dependency:copy-dependencies verify || echo "TEST_FAILED=true" >> $GITHUB_ENV + continue-on-error: true + + - name: Kill Durable Task Sidecar + run: docker kill durabletask-sidecar + + - name: Upload Durable Task Sidecar Logs + uses: actions/upload-artifact@v4 + with: + name: Durable Task Sidecar Logs + path: durabletask-sidecar.log + + - name: Fail the job if tests failed + if: env.TEST_FAILED == 'true' + run: exit 1 + build: name: "Build jdk:${{ matrix.java }} sb:${{ matrix.spring-boot-display-version }} exp:${{ matrix.experimental }}" runs-on: ubuntu-latest @@ -167,36 +209,11 @@ jobs: with: name: surefire-report-sdk-tests-jdk${{ matrix.java }}-sb${{ matrix.spring-boot-version }} path: sdk-tests/target/surefire-reports - # Integration tests for Durable Task Client - - name: Checkout Durable Task Sidecar - uses: actions/checkout@v4 - with: - repository: dapr/durabletask-go - path: durabletask-sidecar - - # TODO: Move the sidecar into a central image repository - - name: Initialize Durable Task Sidecar - run: docker run -d --name durabletask-sidecar -p 4001:4001 --rm -i $(docker build -q ./durabletask-sidecar) - - - name: Display Durable Task Sidecar Logs - run: nohup docker logs --since=0 durabletask-sidecar > durabletask-sidecar.log 2>&1 & - - # wait for 10 seconds, so sidecar container can be fully up, this will avoid intermittent failing issues for integration tests causing by failed to connect to sidecar - - name: Wait for 10 seconds - run: sleep 10 - - - name: Integration Tests For Durable Tasks - run: ./mvnw -B -pl durabletask-client -Pintegration-tests dependency:copy-dependencies verify || echo "TEST_FAILED=true" >> $GITHUB_ENV - continue-on-error: true - - - name: Kill Durable Task Sidecar - run: docker kill durabletask-sidecar - publish: runs-on: ubuntu-latest - needs: [ build, test ] + needs: [ build, test, build-durabletask ] timeout-minutes: 30 env: JDK_VER: 17 From 8e842e8047f681f92a33bc7f37d6f68648eab832 Mon Sep 17 00:00:00 2001 From: salaboy Date: Tue, 21 Oct 2025 13:02:41 +0100 Subject: [PATCH 8/8] adding java to dt build Signed-off-by: salaboy --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eb0dc00da..c87cfc88f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,6 +52,12 @@ jobs: env: JDK_VER: 17 steps: + - uses: actions/checkout@v5 + - name: Set up OpenJDK ${{ env.JDK_VER }} + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JDK_VER }} - name: Checkout Durable Task Sidecar uses: actions/checkout@v4 with: