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 extends Exception> 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