chunks = this.sidecarClient.streamInstanceHistory(request);
+ while (chunks.hasNext()) {
+ for (OrchestratorService.HistoryEvent protoEvent : chunks.next().getEventsList()) {
+ historyEvents.add(HistoryEventConverter.fromProto(protoEvent));
+ }
+ }
+ return historyEvents;
+ }
+
@Override
public void createTaskHub(boolean recreateIfExists) {
this.sidecarClient.createTaskHub(CreateTaskHubRequest.newBuilder().setRecreateIfExists(recreateIfExists).build());
diff --git a/client/src/main/java/com/microsoft/durabletask/EntityQueryPageable.java b/client/src/main/java/com/microsoft/durabletask/EntityQueryPageable.java
index fcf07ab1..07158bf9 100644
--- a/client/src/main/java/com/microsoft/durabletask/EntityQueryPageable.java
+++ b/client/src/main/java/com/microsoft/durabletask/EntityQueryPageable.java
@@ -17,7 +17,7 @@
*
* Use {@link DurableEntityClient#getAllEntities(EntityQuery)} to obtain an instance of this class.
*
- *
Example: iterate over all entities
+ * Example: iterate over all entities
* {@code
* EntityQuery query = new EntityQuery()
* .setInstanceIdStartsWith("counter")
@@ -28,7 +28,7 @@
* }
* }
*
- * Example: iterate page by page
+ * Example: iterate page by page
* {@code
* for (EntityQueryResult page : client.getEntities().getAllEntities(query).byPage()) {
* System.out.println("Got " + page.getEntities().size() + " entities");
diff --git a/client/src/main/java/com/microsoft/durabletask/HistoryEventConverter.java b/client/src/main/java/com/microsoft/durabletask/HistoryEventConverter.java
new file mode 100644
index 00000000..53e3a8d6
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/HistoryEventConverter.java
@@ -0,0 +1,246 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask;
+
+import com.microsoft.durabletask.history.*;
+import com.microsoft.durabletask.implementation.protobuf.OrchestratorService;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * Converts protobuf {@code HistoryEvent} messages into the public {@link HistoryEvent} domain model.
+ */
+final class HistoryEventConverter {
+ private HistoryEventConverter() {
+ }
+
+ /**
+ * Converts a protobuf history event into its domain representation.
+ *
+ * @param proto the protobuf history event
+ * @return the domain {@link HistoryEvent}
+ * @throws IllegalArgumentException if the event type is not set
+ * @throws UnsupportedOperationException if the event type is not recognized
+ */
+ static HistoryEvent fromProto(OrchestratorService.HistoryEvent proto) {
+ int id = proto.getEventId();
+ Instant ts = DataConverter.getInstantFromTimestamp(proto.getTimestamp());
+ switch (proto.getEventTypeCase()) {
+ case EXECUTIONSTARTED: {
+ OrchestratorService.ExecutionStartedEvent p = proto.getExecutionStarted();
+ return new ExecutionStartedEvent(id, ts,
+ p.getName(),
+ stringOrNull(p.hasVersion(), p.getVersion()),
+ stringOrNull(p.hasInput(), p.getInput()),
+ p.hasOrchestrationInstance() ? toInstance(p.getOrchestrationInstance()) : null,
+ p.hasParentInstance() ? toParentInfo(p.getParentInstance()) : null,
+ p.hasScheduledStartTimestamp()
+ ? DataConverter.getInstantFromTimestamp(p.getScheduledStartTimestamp()) : null,
+ p.hasParentTraceContext() ? toTrace(p.getParentTraceContext()) : null,
+ stringOrNull(p.hasOrchestrationSpanID(), p.getOrchestrationSpanID()),
+ p.getTagsMap());
+ }
+ case EXECUTIONCOMPLETED: {
+ OrchestratorService.ExecutionCompletedEvent p = proto.getExecutionCompleted();
+ return new ExecutionCompletedEvent(id, ts,
+ OrchestrationRuntimeStatus.fromProtobuf(p.getOrchestrationStatus()),
+ stringOrNull(p.hasResult(), p.getResult()),
+ p.hasFailureDetails() ? new FailureDetails(p.getFailureDetails()) : null);
+ }
+ case EXECUTIONTERMINATED: {
+ OrchestratorService.ExecutionTerminatedEvent p = proto.getExecutionTerminated();
+ return new ExecutionTerminatedEvent(id, ts, stringOrNull(p.hasInput(), p.getInput()), p.getRecurse());
+ }
+ case TASKSCHEDULED: {
+ OrchestratorService.TaskScheduledEvent p = proto.getTaskScheduled();
+ return new TaskScheduledEvent(id, ts,
+ p.getName(),
+ stringOrNull(p.hasVersion(), p.getVersion()),
+ stringOrNull(p.hasInput(), p.getInput()),
+ p.hasParentTraceContext() ? toTrace(p.getParentTraceContext()) : null,
+ p.getTagsMap());
+ }
+ case TASKCOMPLETED: {
+ OrchestratorService.TaskCompletedEvent p = proto.getTaskCompleted();
+ return new TaskCompletedEvent(id, ts, p.getTaskScheduledId(), stringOrNull(p.hasResult(), p.getResult()));
+ }
+ case TASKFAILED: {
+ OrchestratorService.TaskFailedEvent p = proto.getTaskFailed();
+ return new TaskFailedEvent(id, ts, p.getTaskScheduledId(),
+ p.hasFailureDetails() ? new FailureDetails(p.getFailureDetails()) : null);
+ }
+ case SUBORCHESTRATIONINSTANCECREATED: {
+ OrchestratorService.SubOrchestrationInstanceCreatedEvent p = proto.getSubOrchestrationInstanceCreated();
+ return new SubOrchestrationInstanceCreatedEvent(id, ts,
+ p.getInstanceId(),
+ p.getName(),
+ stringOrNull(p.hasVersion(), p.getVersion()),
+ stringOrNull(p.hasInput(), p.getInput()),
+ p.hasParentTraceContext() ? toTrace(p.getParentTraceContext()) : null,
+ p.getTagsMap());
+ }
+ case SUBORCHESTRATIONINSTANCECOMPLETED: {
+ OrchestratorService.SubOrchestrationInstanceCompletedEvent p =
+ proto.getSubOrchestrationInstanceCompleted();
+ return new SubOrchestrationInstanceCompletedEvent(id, ts,
+ p.getTaskScheduledId(), stringOrNull(p.hasResult(), p.getResult()));
+ }
+ case SUBORCHESTRATIONINSTANCEFAILED: {
+ OrchestratorService.SubOrchestrationInstanceFailedEvent p = proto.getSubOrchestrationInstanceFailed();
+ return new SubOrchestrationInstanceFailedEvent(id, ts, p.getTaskScheduledId(),
+ p.hasFailureDetails() ? new FailureDetails(p.getFailureDetails()) : null);
+ }
+ case TIMERCREATED: {
+ OrchestratorService.TimerCreatedEvent p = proto.getTimerCreated();
+ return new TimerCreatedEvent(id, ts, DataConverter.getInstantFromTimestamp(p.getFireAt()));
+ }
+ case TIMERFIRED: {
+ OrchestratorService.TimerFiredEvent p = proto.getTimerFired();
+ return new TimerFiredEvent(id, ts, DataConverter.getInstantFromTimestamp(p.getFireAt()), p.getTimerId());
+ }
+ case ORCHESTRATORSTARTED:
+ return new OrchestratorStartedEvent(id, ts);
+ case ORCHESTRATORCOMPLETED:
+ return new OrchestratorCompletedEvent(id, ts);
+ case EVENTSENT: {
+ OrchestratorService.EventSentEvent p = proto.getEventSent();
+ return new EventSentEvent(id, ts, p.getInstanceId(), p.getName(),
+ stringOrNull(p.hasInput(), p.getInput()));
+ }
+ case EVENTRAISED: {
+ OrchestratorService.EventRaisedEvent p = proto.getEventRaised();
+ return new EventRaisedEvent(id, ts, p.getName(), stringOrNull(p.hasInput(), p.getInput()));
+ }
+ case GENERICEVENT: {
+ OrchestratorService.GenericEvent p = proto.getGenericEvent();
+ return new GenericEvent(id, ts, stringOrNull(p.hasData(), p.getData()));
+ }
+ case HISTORYSTATE: {
+ OrchestratorService.HistoryStateEvent p = proto.getHistoryState();
+ return new HistoryStateEvent(id, ts,
+ p.hasOrchestrationState() ? toOrchestrationState(p.getOrchestrationState()) : null);
+ }
+ case CONTINUEASNEW: {
+ OrchestratorService.ContinueAsNewEvent p = proto.getContinueAsNew();
+ return new ContinueAsNewEvent(id, ts, stringOrNull(p.hasInput(), p.getInput()));
+ }
+ case EXECUTIONSUSPENDED: {
+ OrchestratorService.ExecutionSuspendedEvent p = proto.getExecutionSuspended();
+ return new ExecutionSuspendedEvent(id, ts, stringOrNull(p.hasInput(), p.getInput()));
+ }
+ case EXECUTIONRESUMED: {
+ OrchestratorService.ExecutionResumedEvent p = proto.getExecutionResumed();
+ return new ExecutionResumedEvent(id, ts, stringOrNull(p.hasInput(), p.getInput()));
+ }
+ case ENTITYOPERATIONSIGNALED: {
+ OrchestratorService.EntityOperationSignaledEvent p = proto.getEntityOperationSignaled();
+ return new EntityOperationSignaledEvent(id, ts,
+ p.getRequestId(),
+ p.getOperation(),
+ p.hasScheduledTime() ? DataConverter.getInstantFromTimestamp(p.getScheduledTime()) : null,
+ stringOrNull(p.hasInput(), p.getInput()),
+ stringOrNull(p.hasTargetInstanceId(), p.getTargetInstanceId()));
+ }
+ case ENTITYOPERATIONCALLED: {
+ OrchestratorService.EntityOperationCalledEvent p = proto.getEntityOperationCalled();
+ return new EntityOperationCalledEvent(id, ts,
+ p.getRequestId(),
+ p.getOperation(),
+ p.hasScheduledTime() ? DataConverter.getInstantFromTimestamp(p.getScheduledTime()) : null,
+ stringOrNull(p.hasInput(), p.getInput()),
+ stringOrNull(p.hasParentInstanceId(), p.getParentInstanceId()),
+ stringOrNull(p.hasParentExecutionId(), p.getParentExecutionId()),
+ stringOrNull(p.hasTargetInstanceId(), p.getTargetInstanceId()));
+ }
+ case ENTITYOPERATIONCOMPLETED: {
+ OrchestratorService.EntityOperationCompletedEvent p = proto.getEntityOperationCompleted();
+ return new EntityOperationCompletedEvent(id, ts, p.getRequestId(),
+ stringOrNull(p.hasOutput(), p.getOutput()));
+ }
+ case ENTITYOPERATIONFAILED: {
+ OrchestratorService.EntityOperationFailedEvent p = proto.getEntityOperationFailed();
+ return new EntityOperationFailedEvent(id, ts, p.getRequestId(),
+ p.hasFailureDetails() ? new FailureDetails(p.getFailureDetails()) : null);
+ }
+ case ENTITYLOCKREQUESTED: {
+ OrchestratorService.EntityLockRequestedEvent p = proto.getEntityLockRequested();
+ return new EntityLockRequestedEvent(id, ts,
+ p.getCriticalSectionId(),
+ p.getLockSetList(),
+ p.getPosition(),
+ stringOrNull(p.hasParentInstanceId(), p.getParentInstanceId()));
+ }
+ case ENTITYLOCKGRANTED: {
+ OrchestratorService.EntityLockGrantedEvent p = proto.getEntityLockGranted();
+ return new EntityLockGrantedEvent(id, ts, p.getCriticalSectionId());
+ }
+ case ENTITYUNLOCKSENT: {
+ OrchestratorService.EntityUnlockSentEvent p = proto.getEntityUnlockSent();
+ return new EntityUnlockSentEvent(id, ts,
+ p.getCriticalSectionId(),
+ stringOrNull(p.hasParentInstanceId(), p.getParentInstanceId()),
+ stringOrNull(p.hasTargetInstanceId(), p.getTargetInstanceId()));
+ }
+ case EXECUTIONREWOUND: {
+ OrchestratorService.ExecutionRewoundEvent p = proto.getExecutionRewound();
+ return new ExecutionRewoundEvent(id, ts,
+ stringOrNull(p.hasReason(), p.getReason()),
+ stringOrNull(p.hasParentExecutionId(), p.getParentExecutionId()),
+ stringOrNull(p.hasInstanceId(), p.getInstanceId()),
+ p.hasParentTraceContext() ? toTrace(p.getParentTraceContext()) : null,
+ stringOrNull(p.hasName(), p.getName()),
+ stringOrNull(p.hasVersion(), p.getVersion()),
+ stringOrNull(p.hasInput(), p.getInput()),
+ p.hasParentInstance() ? toParentInfo(p.getParentInstance()) : null,
+ p.getTagsMap());
+ }
+ case EVENTTYPE_NOT_SET:
+ throw new IllegalArgumentException("History event does not have an eventType set.");
+ default:
+ throw new UnsupportedOperationException(
+ "Deserialization of history event type " + proto.getEventTypeCase() + " is not supported.");
+ }
+ }
+
+ @Nullable
+ private static String stringOrNull(boolean present, com.google.protobuf.StringValue value) {
+ return present ? value.getValue() : null;
+ }
+
+ private static OrchestrationInstance toInstance(OrchestratorService.OrchestrationInstance p) {
+ return new OrchestrationInstance(p.getInstanceId(), stringOrNull(p.hasExecutionId(), p.getExecutionId()));
+ }
+
+ private static ParentInstanceInfo toParentInfo(OrchestratorService.ParentInstanceInfo p) {
+ return new ParentInstanceInfo(
+ p.getTaskScheduledId(),
+ stringOrNull(p.hasName(), p.getName()),
+ stringOrNull(p.hasVersion(), p.getVersion()),
+ p.hasOrchestrationInstance() ? toInstance(p.getOrchestrationInstance()) : null);
+ }
+
+ private static TraceContext toTrace(OrchestratorService.TraceContext p) {
+ return new TraceContext(p.getTraceParent(), stringOrNull(p.hasTraceState(), p.getTraceState()));
+ }
+
+ private static OrchestrationState toOrchestrationState(OrchestratorService.OrchestrationState p) {
+ return new OrchestrationState(
+ p.getInstanceId(),
+ p.getName(),
+ stringOrNull(p.hasVersion(), p.getVersion()),
+ OrchestrationRuntimeStatus.fromProtobuf(p.getOrchestrationStatus()),
+ p.hasScheduledStartTimestamp()
+ ? DataConverter.getInstantFromTimestamp(p.getScheduledStartTimestamp()) : null,
+ p.hasCreatedTimestamp() ? DataConverter.getInstantFromTimestamp(p.getCreatedTimestamp()) : null,
+ p.hasLastUpdatedTimestamp() ? DataConverter.getInstantFromTimestamp(p.getLastUpdatedTimestamp()) : null,
+ p.hasCompletedTimestamp() ? DataConverter.getInstantFromTimestamp(p.getCompletedTimestamp()) : null,
+ stringOrNull(p.hasInput(), p.getInput()),
+ stringOrNull(p.hasOutput(), p.getOutput()),
+ stringOrNull(p.hasCustomStatus(), p.getCustomStatus()),
+ p.hasFailureDetails() ? new FailureDetails(p.getFailureDetails()) : null,
+ stringOrNull(p.hasExecutionId(), p.getExecutionId()),
+ stringOrNull(p.hasParentInstanceId(), p.getParentInstanceId()),
+ p.getTagsMap());
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java b/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java
new file mode 100644
index 00000000..2834a246
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Query for listing terminal orchestration instance IDs by completion-time window.
+ *
+ * Used with {@link DurableTaskClient#listInstanceIds(ListInstanceIdsQuery)} to enumerate the IDs of orchestration
+ * instances that reached a terminal state within a completion-time window. Unlike {@link OrchestrationStatusQuery}
+ * (which filters by creation time and returns full metadata), this query filters by completion time and
+ * returns only instance IDs, making it efficient for bulk enumeration such as archival/export.
+ */
+public final class ListInstanceIdsQuery {
+ private List runtimeStatusList = new ArrayList<>();
+ private Instant completedTimeFrom;
+ private Instant completedTimeTo;
+ private int pageSize = 100;
+ private String continuationToken;
+
+ /**
+ * Sole constructor.
+ */
+ public ListInstanceIdsQuery() {
+ }
+
+ /**
+ * Sets the terminal runtime status values to filter by. Only instances with a matching status are returned.
+ * The default empty list disables runtime-status filtering.
+ *
+ * @param runtimeStatusList the terminal runtime statuses to filter by (e.g. COMPLETED, FAILED, TERMINATED)
+ * @return this query object
+ */
+ public ListInstanceIdsQuery setRuntimeStatusList(@Nullable List runtimeStatusList) {
+ this.runtimeStatusList = runtimeStatusList != null ? new ArrayList<>(runtimeStatusList) : new ArrayList<>();
+ return this;
+ }
+
+ /**
+ * Includes instances that completed at or after the specified instant.
+ *
+ * @param completedTimeFrom the minimum completion time, or {@code null} to disable this filter
+ * @return this query object
+ */
+ public ListInstanceIdsQuery setCompletedTimeFrom(@Nullable Instant completedTimeFrom) {
+ this.completedTimeFrom = completedTimeFrom;
+ return this;
+ }
+
+ /**
+ * Includes instances that completed before the specified instant.
+ *
+ * @param completedTimeTo the maximum completion time, or {@code null} to disable this filter
+ * @return this query object
+ */
+ public ListInstanceIdsQuery setCompletedTimeTo(@Nullable Instant completedTimeTo) {
+ this.completedTimeTo = completedTimeTo;
+ return this;
+ }
+
+ /**
+ * Sets the maximum number of instance IDs to return per page. The default value is 100.
+ *
+ * A page may contain fewer IDs than the page size even when more results exist; always use
+ * {@link ListInstanceIdsResult#getContinuationToken()} to determine whether to continue paging.
+ *
+ * @param pageSize the maximum number of instance IDs to return per page; must be greater than zero
+ * @return this query object
+ * @throws IllegalArgumentException if {@code pageSize} is less than 1
+ */
+ public ListInstanceIdsQuery setPageSize(int pageSize) {
+ if (pageSize < 1) {
+ throw new IllegalArgumentException("pageSize must be at least 1.");
+ }
+ this.pageSize = pageSize;
+ return this;
+ }
+
+ /**
+ * Sets the pagination cursor used to continue listing from a previous page.
+ *
+ * This should be the {@link ListInstanceIdsResult#getContinuationToken()} value from the previous page.
+ *
+ * @param continuationToken the cursor from the previous page, or {@code null} to start from the beginning
+ * @return this query object
+ */
+ public ListInstanceIdsQuery setContinuationToken(@Nullable String continuationToken) {
+ this.continuationToken = continuationToken;
+ return this;
+ }
+
+ List getRuntimeStatusList() {
+ return this.runtimeStatusList;
+ }
+
+ @Nullable
+ Instant getCompletedTimeFrom() {
+ return this.completedTimeFrom;
+ }
+
+ @Nullable
+ Instant getCompletedTimeTo() {
+ return this.completedTimeTo;
+ }
+
+ int getPageSize() {
+ return this.pageSize;
+ }
+
+ @Nullable
+ String getContinuationToken() {
+ return this.continuationToken;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsResult.java b/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsResult.java
new file mode 100644
index 00000000..b5afba9c
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsResult.java
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask;
+
+import javax.annotation.Nullable;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Result of a {@link DurableTaskClient#listInstanceIds(ListInstanceIdsQuery)} call.
+ *
+ * Contains a page of terminal orchestration instance IDs and a pagination cursor for fetching the next page.
+ */
+public final class ListInstanceIdsResult {
+ private final List instanceIds;
+ private final String continuationToken;
+
+ ListInstanceIdsResult(List instanceIds, @Nullable String continuationToken) {
+ this.instanceIds = Collections.unmodifiableList(instanceIds);
+ this.continuationToken = continuationToken;
+ }
+
+ /**
+ * Gets the page of terminal orchestration instance IDs that matched the query.
+ *
+ * @return an unmodifiable list of instance IDs that matched the query
+ */
+ public List getInstanceIds() {
+ return this.instanceIds;
+ }
+
+ /**
+ * Gets the pagination cursor to pass to the next
+ * {@link DurableTaskClient#listInstanceIds(ListInstanceIdsQuery)} call via
+ * {@link ListInstanceIdsQuery#setContinuationToken(String)}, or {@code null} when there are no more pages.
+ *
+ * @return the cursor for the next page, or {@code null} if no more pages are available
+ */
+ @Nullable
+ public String getContinuationToken() {
+ return this.continuationToken;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/TypedEntityMetadata.java b/client/src/main/java/com/microsoft/durabletask/TypedEntityMetadata.java
index ff059a9e..f47f2de4 100644
--- a/client/src/main/java/com/microsoft/durabletask/TypedEntityMetadata.java
+++ b/client/src/main/java/com/microsoft/durabletask/TypedEntityMetadata.java
@@ -14,7 +14,7 @@
* and provides a typed {@code State} property. In Java, the state is eagerly deserialized and accessible
* via {@link #getState()}.
*
- * Example:
+ * Example:
* {@code
* TypedEntityMetadata metadata = client.getEntities()
* .getEntityMetadata(entityId, Integer.class);
diff --git a/client/src/main/java/com/microsoft/durabletask/TypedEntityQueryPageable.java b/client/src/main/java/com/microsoft/durabletask/TypedEntityQueryPageable.java
index a8a7c1a9..98297b55 100644
--- a/client/src/main/java/com/microsoft/durabletask/TypedEntityQueryPageable.java
+++ b/client/src/main/java/com/microsoft/durabletask/TypedEntityQueryPageable.java
@@ -14,7 +14,7 @@
*
* Use {@link DurableEntityClient#getAllEntities(EntityQuery, Class)} to obtain an instance.
*
- *
Example:
+ * Example:
* {@code
* EntityQuery query = new EntityQuery().setInstanceIdStartsWith("counter");
* for (TypedEntityMetadata entity : client.getEntities().getAllEntities(query, Integer.class)) {
diff --git a/client/src/main/java/com/microsoft/durabletask/history/ContinueAsNewEvent.java b/client/src/main/java/com/microsoft/durabletask/history/ContinueAsNewEvent.java
new file mode 100644
index 00000000..6b80aebe
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/ContinueAsNewEvent.java
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when an orchestration restarts itself via continue-as-new.
+ */
+public final class ContinueAsNewEvent extends HistoryEvent {
+ private final String input;
+
+ /**
+ * Creates a new {@code ContinueAsNewEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param input the serialized input for the new generation, or {@code null}
+ */
+ public ContinueAsNewEvent(int eventId, Instant timestamp, @Nullable String input) {
+ super(eventId, timestamp);
+ this.input = input;
+ }
+
+ /** @return the serialized input for the new generation, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/EntityLockGrantedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/EntityLockGrantedEvent.java
new file mode 100644
index 00000000..d542f611
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/EntityLockGrantedEvent.java
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import java.time.Instant;
+
+/**
+ * History event recorded when a requested entity lock is granted.
+ */
+public final class EntityLockGrantedEvent extends HistoryEvent {
+ private final String criticalSectionId;
+
+ /**
+ * Creates a new {@code EntityLockGrantedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param criticalSectionId the ID of the critical section whose lock was granted
+ */
+ public EntityLockGrantedEvent(int eventId, Instant timestamp, String criticalSectionId) {
+ super(eventId, timestamp);
+ this.criticalSectionId = criticalSectionId;
+ }
+
+ /** @return the ID of the critical section whose lock was granted. */
+ public String getCriticalSectionId() {
+ return this.criticalSectionId;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/EntityLockRequestedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/EntityLockRequestedEvent.java
new file mode 100644
index 00000000..8a062d95
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/EntityLockRequestedEvent.java
@@ -0,0 +1,65 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * History event recorded when an orchestration requests a lock over one or more durable entities.
+ */
+public final class EntityLockRequestedEvent extends HistoryEvent {
+ private final String criticalSectionId;
+ private final List lockSet;
+ private final int position;
+ private final String parentInstanceId;
+
+ /**
+ * Creates a new {@code EntityLockRequestedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param criticalSectionId the ID of the critical section associated with the lock request
+ * @param lockSet the ordered set of entity instance IDs being locked, or {@code null}
+ * @param position the position of this entity within the lock set
+ * @param parentInstanceId the requesting instance ID, or {@code null}
+ */
+ public EntityLockRequestedEvent(
+ int eventId,
+ Instant timestamp,
+ String criticalSectionId,
+ @Nullable List lockSet,
+ int position,
+ @Nullable String parentInstanceId) {
+ super(eventId, timestamp);
+ this.criticalSectionId = criticalSectionId;
+ this.lockSet = lockSet != null
+ ? Collections.unmodifiableList(new ArrayList<>(lockSet)) : Collections.emptyList();
+ this.position = position;
+ this.parentInstanceId = parentInstanceId;
+ }
+
+ /** @return the ID of the critical section associated with the lock request. */
+ public String getCriticalSectionId() {
+ return this.criticalSectionId;
+ }
+
+ /** @return the ordered set of entity instance IDs being locked (never {@code null}). */
+ public List getLockSet() {
+ return this.lockSet;
+ }
+
+ /** @return the position of this entity within the lock set. */
+ public int getPosition() {
+ return this.position;
+ }
+
+ /** @return the requesting instance ID, or {@code null} if not set. */
+ @Nullable
+ public String getParentInstanceId() {
+ return this.parentInstanceId;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/EntityOperationCalledEvent.java b/client/src/main/java/com/microsoft/durabletask/history/EntityOperationCalledEvent.java
new file mode 100644
index 00000000..f85a7761
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/EntityOperationCalledEvent.java
@@ -0,0 +1,92 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when a two-way (call) operation is sent to a durable entity.
+ */
+public final class EntityOperationCalledEvent extends HistoryEvent {
+ private final String requestId;
+ private final String operation;
+ private final Instant scheduledTime;
+ private final String input;
+ private final String parentInstanceId;
+ private final String parentExecutionId;
+ private final String targetInstanceId;
+
+ /**
+ * Creates a new {@code EntityOperationCalledEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param requestId the unique request ID of the entity operation
+ * @param operation the name of the entity operation
+ * @param scheduledTime the scheduled delivery time, or {@code null}
+ * @param input the serialized operation input, or {@code null}
+ * @param parentInstanceId the calling instance ID, or {@code null}
+ * @param parentExecutionId the calling execution ID, or {@code null}
+ * @param targetInstanceId the target entity instance ID, or {@code null}
+ */
+ public EntityOperationCalledEvent(
+ int eventId,
+ Instant timestamp,
+ String requestId,
+ String operation,
+ @Nullable Instant scheduledTime,
+ @Nullable String input,
+ @Nullable String parentInstanceId,
+ @Nullable String parentExecutionId,
+ @Nullable String targetInstanceId) {
+ super(eventId, timestamp);
+ this.requestId = requestId;
+ this.operation = operation;
+ this.scheduledTime = scheduledTime;
+ this.input = input;
+ this.parentInstanceId = parentInstanceId;
+ this.parentExecutionId = parentExecutionId;
+ this.targetInstanceId = targetInstanceId;
+ }
+
+ /** @return the unique request ID of the entity operation. */
+ public String getRequestId() {
+ return this.requestId;
+ }
+
+ /** @return the name of the entity operation. */
+ public String getOperation() {
+ return this.operation;
+ }
+
+ /** @return the scheduled delivery time, or {@code null} if delivered immediately. */
+ @Nullable
+ public Instant getScheduledTime() {
+ return this.scheduledTime;
+ }
+
+ /** @return the serialized operation input, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+
+ /** @return the calling instance ID, or {@code null} if not set. */
+ @Nullable
+ public String getParentInstanceId() {
+ return this.parentInstanceId;
+ }
+
+ /** @return the calling execution ID, or {@code null} if not set. */
+ @Nullable
+ public String getParentExecutionId() {
+ return this.parentExecutionId;
+ }
+
+ /** @return the target entity instance ID, or {@code null} if not set. */
+ @Nullable
+ public String getTargetInstanceId() {
+ return this.targetInstanceId;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/EntityOperationCompletedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/EntityOperationCompletedEvent.java
new file mode 100644
index 00000000..d8c188ed
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/EntityOperationCompletedEvent.java
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when a durable entity operation completes successfully.
+ */
+public final class EntityOperationCompletedEvent extends HistoryEvent {
+ private final String requestId;
+ private final String output;
+
+ /**
+ * Creates a new {@code EntityOperationCompletedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param requestId the unique request ID of the entity operation
+ * @param output the serialized operation output, or {@code null}
+ */
+ public EntityOperationCompletedEvent(
+ int eventId, Instant timestamp, String requestId, @Nullable String output) {
+ super(eventId, timestamp);
+ this.requestId = requestId;
+ this.output = output;
+ }
+
+ /** @return the unique request ID of the entity operation. */
+ public String getRequestId() {
+ return this.requestId;
+ }
+
+ /** @return the serialized operation output, or {@code null} if none. */
+ @Nullable
+ public String getOutput() {
+ return this.output;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/EntityOperationFailedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/EntityOperationFailedEvent.java
new file mode 100644
index 00000000..a4d2cf33
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/EntityOperationFailedEvent.java
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import com.microsoft.durabletask.FailureDetails;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when a durable entity operation fails.
+ */
+public final class EntityOperationFailedEvent extends HistoryEvent {
+ private final String requestId;
+ private final FailureDetails failureDetails;
+
+ /**
+ * Creates a new {@code EntityOperationFailedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param requestId the unique request ID of the entity operation
+ * @param failureDetails the failure details, or {@code null}
+ */
+ public EntityOperationFailedEvent(
+ int eventId, Instant timestamp, String requestId, @Nullable FailureDetails failureDetails) {
+ super(eventId, timestamp);
+ this.requestId = requestId;
+ this.failureDetails = failureDetails;
+ }
+
+ /** @return the unique request ID of the entity operation. */
+ public String getRequestId() {
+ return this.requestId;
+ }
+
+ /** @return the failure details, or {@code null} if not available. */
+ @Nullable
+ public FailureDetails getFailureDetails() {
+ return this.failureDetails;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/EntityOperationSignaledEvent.java b/client/src/main/java/com/microsoft/durabletask/history/EntityOperationSignaledEvent.java
new file mode 100644
index 00000000..f299d59d
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/EntityOperationSignaledEvent.java
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when a one-way (signal) operation is sent to a durable entity.
+ */
+public final class EntityOperationSignaledEvent extends HistoryEvent {
+ private final String requestId;
+ private final String operation;
+ private final Instant scheduledTime;
+ private final String input;
+ private final String targetInstanceId;
+
+ /**
+ * Creates a new {@code EntityOperationSignaledEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param requestId the unique request ID of the entity operation
+ * @param operation the name of the entity operation
+ * @param scheduledTime the scheduled delivery time, or {@code null}
+ * @param input the serialized operation input, or {@code null}
+ * @param targetInstanceId the target entity instance ID, or {@code null}
+ */
+ public EntityOperationSignaledEvent(
+ int eventId,
+ Instant timestamp,
+ String requestId,
+ String operation,
+ @Nullable Instant scheduledTime,
+ @Nullable String input,
+ @Nullable String targetInstanceId) {
+ super(eventId, timestamp);
+ this.requestId = requestId;
+ this.operation = operation;
+ this.scheduledTime = scheduledTime;
+ this.input = input;
+ this.targetInstanceId = targetInstanceId;
+ }
+
+ /** @return the unique request ID of the entity operation. */
+ public String getRequestId() {
+ return this.requestId;
+ }
+
+ /** @return the name of the entity operation. */
+ public String getOperation() {
+ return this.operation;
+ }
+
+ /** @return the scheduled delivery time, or {@code null} if delivered immediately. */
+ @Nullable
+ public Instant getScheduledTime() {
+ return this.scheduledTime;
+ }
+
+ /** @return the serialized operation input, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+
+ /** @return the target entity instance ID, or {@code null} if not set. */
+ @Nullable
+ public String getTargetInstanceId() {
+ return this.targetInstanceId;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/EntityUnlockSentEvent.java b/client/src/main/java/com/microsoft/durabletask/history/EntityUnlockSentEvent.java
new file mode 100644
index 00000000..7c12a928
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/EntityUnlockSentEvent.java
@@ -0,0 +1,53 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when an entity lock is released (unlock sent).
+ */
+public final class EntityUnlockSentEvent extends HistoryEvent {
+ private final String criticalSectionId;
+ private final String parentInstanceId;
+ private final String targetInstanceId;
+
+ /**
+ * Creates a new {@code EntityUnlockSentEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param criticalSectionId the ID of the critical section being released
+ * @param parentInstanceId the releasing instance ID, or {@code null}
+ * @param targetInstanceId the target entity instance ID, or {@code null}
+ */
+ public EntityUnlockSentEvent(
+ int eventId,
+ Instant timestamp,
+ String criticalSectionId,
+ @Nullable String parentInstanceId,
+ @Nullable String targetInstanceId) {
+ super(eventId, timestamp);
+ this.criticalSectionId = criticalSectionId;
+ this.parentInstanceId = parentInstanceId;
+ this.targetInstanceId = targetInstanceId;
+ }
+
+ /** @return the ID of the critical section being released. */
+ public String getCriticalSectionId() {
+ return this.criticalSectionId;
+ }
+
+ /** @return the releasing instance ID, or {@code null} if not set. */
+ @Nullable
+ public String getParentInstanceId() {
+ return this.parentInstanceId;
+ }
+
+ /** @return the target entity instance ID, or {@code null} if not set. */
+ @Nullable
+ public String getTargetInstanceId() {
+ return this.targetInstanceId;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/EventRaisedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/EventRaisedEvent.java
new file mode 100644
index 00000000..a8bde472
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/EventRaisedEvent.java
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when an external event is delivered to an orchestration instance.
+ */
+public final class EventRaisedEvent extends HistoryEvent {
+ private final String name;
+ private final String input;
+
+ /**
+ * Creates a new {@code EventRaisedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param name the name of the event
+ * @param input the serialized event payload, or {@code null}
+ */
+ public EventRaisedEvent(int eventId, Instant timestamp, String name, @Nullable String input) {
+ super(eventId, timestamp);
+ this.name = name;
+ this.input = input;
+ }
+
+ /** @return the name of the event. */
+ public String getName() {
+ return this.name;
+ }
+
+ /** @return the serialized event payload, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/EventSentEvent.java b/client/src/main/java/com/microsoft/durabletask/history/EventSentEvent.java
new file mode 100644
index 00000000..c4accf8d
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/EventSentEvent.java
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when an orchestration sends an external event to another instance.
+ */
+public final class EventSentEvent extends HistoryEvent {
+ private final String instanceId;
+ private final String name;
+ private final String input;
+
+ /**
+ * Creates a new {@code EventSentEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param instanceId the target instance ID the event was sent to
+ * @param name the name of the event
+ * @param input the serialized event payload, or {@code null}
+ */
+ public EventSentEvent(int eventId, Instant timestamp, String instanceId, String name, @Nullable String input) {
+ super(eventId, timestamp);
+ this.instanceId = instanceId;
+ this.name = name;
+ this.input = input;
+ }
+
+ /** @return the target instance ID the event was sent to. */
+ public String getInstanceId() {
+ return this.instanceId;
+ }
+
+ /** @return the name of the event. */
+ public String getName() {
+ return this.name;
+ }
+
+ /** @return the serialized event payload, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/ExecutionCompletedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/ExecutionCompletedEvent.java
new file mode 100644
index 00000000..f0cdae0a
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/ExecutionCompletedEvent.java
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import com.microsoft.durabletask.FailureDetails;
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when an orchestration instance reaches a terminal state.
+ */
+public final class ExecutionCompletedEvent extends HistoryEvent {
+ private final OrchestrationRuntimeStatus orchestrationStatus;
+ private final String result;
+ private final FailureDetails failureDetails;
+
+ /**
+ * Creates a new {@code ExecutionCompletedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param orchestrationStatus the terminal runtime status
+ * @param result the serialized orchestration output, or {@code null}
+ * @param failureDetails the failure details if the orchestration failed, or {@code null}
+ */
+ public ExecutionCompletedEvent(
+ int eventId,
+ Instant timestamp,
+ OrchestrationRuntimeStatus orchestrationStatus,
+ @Nullable String result,
+ @Nullable FailureDetails failureDetails) {
+ super(eventId, timestamp);
+ this.orchestrationStatus = orchestrationStatus;
+ this.result = result;
+ this.failureDetails = failureDetails;
+ }
+
+ /** @return the terminal runtime status of the orchestration. */
+ public OrchestrationRuntimeStatus getOrchestrationStatus() {
+ return this.orchestrationStatus;
+ }
+
+ /** @return the serialized orchestration output, or {@code null} if none. */
+ @Nullable
+ public String getResult() {
+ return this.result;
+ }
+
+ /** @return the failure details if the orchestration failed, otherwise {@code null}. */
+ @Nullable
+ public FailureDetails getFailureDetails() {
+ return this.failureDetails;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/ExecutionResumedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/ExecutionResumedEvent.java
new file mode 100644
index 00000000..5dbf2ebd
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/ExecutionResumedEvent.java
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when a suspended orchestration instance is resumed.
+ */
+public final class ExecutionResumedEvent extends HistoryEvent {
+ private final String input;
+
+ /**
+ * Creates a new {@code ExecutionResumedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param input the serialized resume reason, or {@code null}
+ */
+ public ExecutionResumedEvent(int eventId, Instant timestamp, @Nullable String input) {
+ super(eventId, timestamp);
+ this.input = input;
+ }
+
+ /** @return the serialized resume reason, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/ExecutionRewoundEvent.java b/client/src/main/java/com/microsoft/durabletask/history/ExecutionRewoundEvent.java
new file mode 100644
index 00000000..afe49f09
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/ExecutionRewoundEvent.java
@@ -0,0 +1,116 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * History event recorded when an orchestration instance is rewound to a previous good state.
+ */
+public final class ExecutionRewoundEvent extends HistoryEvent {
+ private final String reason;
+ private final String parentExecutionId;
+ private final String instanceId;
+ private final TraceContext parentTraceContext;
+ private final String name;
+ private final String version;
+ private final String input;
+ private final ParentInstanceInfo parentInstance;
+ private final Map tags;
+
+ /**
+ * Creates a new {@code ExecutionRewoundEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param reason the reason for the rewind, or {@code null}
+ * @param parentExecutionId the parent execution ID (sub-orchestration rewind only), or {@code null}
+ * @param instanceId the instance ID (sub-orchestration rewind only), or {@code null}
+ * @param parentTraceContext the parent distributed-tracing context, or {@code null}
+ * @param name the orchestrator name, or {@code null}
+ * @param version the orchestrator version, or {@code null}
+ * @param input the serialized input, or {@code null}
+ * @param parentInstance the parent orchestration info, or {@code null}
+ * @param tags the orchestration tags, or {@code null}
+ */
+ public ExecutionRewoundEvent(
+ int eventId,
+ Instant timestamp,
+ @Nullable String reason,
+ @Nullable String parentExecutionId,
+ @Nullable String instanceId,
+ @Nullable TraceContext parentTraceContext,
+ @Nullable String name,
+ @Nullable String version,
+ @Nullable String input,
+ @Nullable ParentInstanceInfo parentInstance,
+ @Nullable Map tags) {
+ super(eventId, timestamp);
+ this.reason = reason;
+ this.parentExecutionId = parentExecutionId;
+ this.instanceId = instanceId;
+ this.parentTraceContext = parentTraceContext;
+ this.name = name;
+ this.version = version;
+ this.input = input;
+ this.parentInstance = parentInstance;
+ this.tags = tags != null ? Collections.unmodifiableMap(new HashMap<>(tags)) : Collections.emptyMap();
+ }
+
+ /** @return the reason for the rewind, or {@code null} if none. */
+ @Nullable
+ public String getReason() {
+ return this.reason;
+ }
+
+ /** @return the parent execution ID (sub-orchestration rewind only), or {@code null}. */
+ @Nullable
+ public String getParentExecutionId() {
+ return this.parentExecutionId;
+ }
+
+ /** @return the instance ID (sub-orchestration rewind only), or {@code null}. */
+ @Nullable
+ public String getInstanceId() {
+ return this.instanceId;
+ }
+
+ /** @return the parent distributed-tracing context, or {@code null} if not set. */
+ @Nullable
+ public TraceContext getParentTraceContext() {
+ return this.parentTraceContext;
+ }
+
+ /** @return the orchestrator name, or {@code null} if not set. */
+ @Nullable
+ public String getName() {
+ return this.name;
+ }
+
+ /** @return the orchestrator version, or {@code null} if not set. */
+ @Nullable
+ public String getVersion() {
+ return this.version;
+ }
+
+ /** @return the serialized input, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+
+ /** @return the parent orchestration info, or {@code null} if not set. */
+ @Nullable
+ public ParentInstanceInfo getParentInstance() {
+ return this.parentInstance;
+ }
+
+ /** @return the orchestration tags (never {@code null}; empty when none). */
+ public Map getTags() {
+ return this.tags;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/ExecutionStartedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/ExecutionStartedEvent.java
new file mode 100644
index 00000000..a6173e3c
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/ExecutionStartedEvent.java
@@ -0,0 +1,115 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * History event recorded when an orchestration instance begins execution.
+ */
+public final class ExecutionStartedEvent extends HistoryEvent {
+ private final String name;
+ private final String version;
+ private final String input;
+ private final OrchestrationInstance orchestrationInstance;
+ private final ParentInstanceInfo parentInstance;
+ private final Instant scheduledStartTimestamp;
+ private final TraceContext parentTraceContext;
+ private final String orchestrationSpanId;
+ private final Map tags;
+
+ /**
+ * Creates a new {@code ExecutionStartedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param name the orchestrator name
+ * @param version the orchestrator version, or {@code null}
+ * @param input the serialized orchestration input, or {@code null}
+ * @param orchestrationInstance the orchestration instance, or {@code null}
+ * @param parentInstance the parent orchestration info, or {@code null}
+ * @param scheduledStartTimestamp the scheduled start time for delayed starts, or {@code null}
+ * @param parentTraceContext the parent distributed-tracing context, or {@code null}
+ * @param orchestrationSpanId the orchestration's tracing span ID, or {@code null}
+ * @param tags the orchestration tags, or {@code null}
+ */
+ public ExecutionStartedEvent(
+ int eventId,
+ Instant timestamp,
+ String name,
+ @Nullable String version,
+ @Nullable String input,
+ @Nullable OrchestrationInstance orchestrationInstance,
+ @Nullable ParentInstanceInfo parentInstance,
+ @Nullable Instant scheduledStartTimestamp,
+ @Nullable TraceContext parentTraceContext,
+ @Nullable String orchestrationSpanId,
+ @Nullable Map tags) {
+ super(eventId, timestamp);
+ this.name = name;
+ this.version = version;
+ this.input = input;
+ this.orchestrationInstance = orchestrationInstance;
+ this.parentInstance = parentInstance;
+ this.scheduledStartTimestamp = scheduledStartTimestamp;
+ this.parentTraceContext = parentTraceContext;
+ this.orchestrationSpanId = orchestrationSpanId;
+ this.tags = tags != null ? Collections.unmodifiableMap(new HashMap<>(tags)) : Collections.emptyMap();
+ }
+
+ /** @return the name of the orchestrator. */
+ public String getName() {
+ return this.name;
+ }
+
+ /** @return the orchestrator version, or {@code null} if not set. */
+ @Nullable
+ public String getVersion() {
+ return this.version;
+ }
+
+ /** @return the serialized orchestration input, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+
+ /** @return the orchestration instance, or {@code null} if not set. */
+ @Nullable
+ public OrchestrationInstance getOrchestrationInstance() {
+ return this.orchestrationInstance;
+ }
+
+ /** @return the parent orchestration info if this is a sub-orchestration, otherwise {@code null}. */
+ @Nullable
+ public ParentInstanceInfo getParentInstance() {
+ return this.parentInstance;
+ }
+
+ /** @return the scheduled start time for delayed starts, or {@code null} if started immediately. */
+ @Nullable
+ public Instant getScheduledStartTimestamp() {
+ return this.scheduledStartTimestamp;
+ }
+
+ /** @return the distributed-tracing context of the parent, or {@code null} if not set. */
+ @Nullable
+ public TraceContext getParentTraceContext() {
+ return this.parentTraceContext;
+ }
+
+ /** @return the orchestration's tracing span ID, or {@code null} if not set. */
+ @Nullable
+ public String getOrchestrationSpanId() {
+ return this.orchestrationSpanId;
+ }
+
+ /** @return the orchestration tags (never {@code null}; empty when none). */
+ public Map getTags() {
+ return this.tags;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/ExecutionSuspendedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/ExecutionSuspendedEvent.java
new file mode 100644
index 00000000..4115f9d1
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/ExecutionSuspendedEvent.java
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when an orchestration instance is suspended.
+ */
+public final class ExecutionSuspendedEvent extends HistoryEvent {
+ private final String input;
+
+ /**
+ * Creates a new {@code ExecutionSuspendedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param input the serialized suspension reason, or {@code null}
+ */
+ public ExecutionSuspendedEvent(int eventId, Instant timestamp, @Nullable String input) {
+ super(eventId, timestamp);
+ this.input = input;
+ }
+
+ /** @return the serialized suspension reason, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/ExecutionTerminatedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/ExecutionTerminatedEvent.java
new file mode 100644
index 00000000..2498518c
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/ExecutionTerminatedEvent.java
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when an orchestration instance is terminated.
+ */
+public final class ExecutionTerminatedEvent extends HistoryEvent {
+ private final String input;
+ private final boolean recurse;
+
+ /**
+ * Creates a new {@code ExecutionTerminatedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param input the serialized termination input/reason, or {@code null}
+ * @param recurse whether termination recurses into sub-orchestrations
+ */
+ public ExecutionTerminatedEvent(int eventId, Instant timestamp, @Nullable String input, boolean recurse) {
+ super(eventId, timestamp);
+ this.input = input;
+ this.recurse = recurse;
+ }
+
+ /** @return the serialized termination input/reason, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+
+ /** @return whether termination recurses into sub-orchestrations. */
+ public boolean isRecurse() {
+ return this.recurse;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/GenericEvent.java b/client/src/main/java/com/microsoft/durabletask/history/GenericEvent.java
new file mode 100644
index 00000000..b23b4fa7
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/GenericEvent.java
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event that carries a generic, free-form data payload.
+ */
+public final class GenericEvent extends HistoryEvent {
+ private final String data;
+
+ /**
+ * Creates a new {@code GenericEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param data the serialized event data, or {@code null}
+ */
+ public GenericEvent(int eventId, Instant timestamp, @Nullable String data) {
+ super(eventId, timestamp);
+ this.data = data;
+ }
+
+ /** @return the serialized event data, or {@code null} if none. */
+ @Nullable
+ public String getData() {
+ return this.data;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/HistoryEvent.java b/client/src/main/java/com/microsoft/durabletask/history/HistoryEvent.java
new file mode 100644
index 00000000..59511cca
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/HistoryEvent.java
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import java.time.Instant;
+
+/**
+ * Base class for the events that make up an orchestration instance's execution history.
+ *
+ * Instances are obtained from {@link com.microsoft.durabletask.DurableTaskClient#getOrchestrationHistory(String)}. Each
+ * concrete subclass (for example {@link ExecutionStartedEvent} or {@link TaskCompletedEvent}) exposes the data specific
+ * to that event type. Use {@code instanceof} to inspect the concrete event type.
+ */
+public abstract class HistoryEvent {
+ private final int eventId;
+ private final Instant timestamp;
+
+ HistoryEvent(int eventId, Instant timestamp) {
+ this.eventId = eventId;
+ this.timestamp = timestamp;
+ }
+
+ /**
+ * Gets the sequence ID of this history event, or {@code -1} if the event is not associated with a specific action.
+ *
+ * @return the event sequence ID
+ */
+ public int getEventId() {
+ return this.eventId;
+ }
+
+ /**
+ * Gets the UTC timestamp at which this history event was recorded.
+ *
+ * @return the event timestamp
+ */
+ public Instant getTimestamp() {
+ return this.timestamp;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/HistoryStateEvent.java b/client/src/main/java/com/microsoft/durabletask/history/HistoryStateEvent.java
new file mode 100644
index 00000000..4f233c1b
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/HistoryStateEvent.java
@@ -0,0 +1,34 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event that carries a snapshot of the orchestration's runtime state.
+ *
+ * This is an internal checkpoint marker. The full state snapshot is surfaced via {@link #getState()}, matching the
+ * sibling .NET SDK ({@code HistoryStateEvent.State}) and Python SDK ({@code HistoryStateEvent.orchestration_state}).
+ */
+public final class HistoryStateEvent extends HistoryEvent {
+ private final OrchestrationState state;
+
+ /**
+ * Creates a new {@code HistoryStateEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param state the orchestration state snapshot, or {@code null} if not available
+ */
+ public HistoryStateEvent(int eventId, Instant timestamp, @Nullable OrchestrationState state) {
+ super(eventId, timestamp);
+ this.state = state;
+ }
+
+ /** @return the orchestration state snapshot, or {@code null} if not available. */
+ @Nullable
+ public OrchestrationState getState() {
+ return this.state;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/OrchestrationInstance.java b/client/src/main/java/com/microsoft/durabletask/history/OrchestrationInstance.java
new file mode 100644
index 00000000..c55a421d
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/OrchestrationInstance.java
@@ -0,0 +1,35 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+
+/**
+ * Identifies an orchestration instance and, optionally, a specific execution (generation) of it.
+ */
+public final class OrchestrationInstance {
+ private final String instanceId;
+ private final String executionId;
+
+ /**
+ * Creates a new {@code OrchestrationInstance}.
+ *
+ * @param instanceId the orchestration instance ID
+ * @param executionId the execution (generation) ID, or {@code null}
+ */
+ public OrchestrationInstance(String instanceId, @Nullable String executionId) {
+ this.instanceId = instanceId;
+ this.executionId = executionId;
+ }
+
+ /** @return the unique ID of the orchestration instance. */
+ public String getInstanceId() {
+ return this.instanceId;
+ }
+
+ /** @return the execution (generation) ID, or {@code null} if not set. */
+ @Nullable
+ public String getExecutionId() {
+ return this.executionId;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/OrchestrationState.java b/client/src/main/java/com/microsoft/durabletask/history/OrchestrationState.java
new file mode 100644
index 00000000..40fcd78b
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/OrchestrationState.java
@@ -0,0 +1,176 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import com.microsoft.durabletask.FailureDetails;
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A snapshot of an orchestration instance's runtime state, as carried by a {@link HistoryStateEvent}.
+ *
+ * This mirrors the {@code OrchestrationState} surfaced by the sibling .NET and Python SDKs (the .NET
+ * {@code HistoryStateEvent.State} property and the Python {@code HistoryStateEvent.orchestration_state} value), so that
+ * archival/export consumers can capture the full state checkpoint rather than just the instance ID.
+ */
+public final class OrchestrationState {
+ private final String instanceId;
+ private final String name;
+ private final String version;
+ private final OrchestrationRuntimeStatus runtimeStatus;
+ private final Instant scheduledStartTime;
+ private final Instant createdTime;
+ private final Instant lastUpdatedTime;
+ private final Instant completedTime;
+ private final String input;
+ private final String output;
+ private final String customStatus;
+ private final FailureDetails failureDetails;
+ private final String executionId;
+ private final String parentInstanceId;
+ private final Map tags;
+
+ /**
+ * Creates a new {@code OrchestrationState}.
+ *
+ * @param instanceId the orchestration instance ID
+ * @param name the orchestration name
+ * @param version the orchestration version, or {@code null}
+ * @param runtimeStatus the runtime status of the orchestration
+ * @param scheduledStartTime the scheduled start time, or {@code null}
+ * @param createdTime the creation time, or {@code null}
+ * @param lastUpdatedTime the last-updated time, or {@code null}
+ * @param completedTime the completion time, or {@code null}
+ * @param input the serialized input, or {@code null}
+ * @param output the serialized output, or {@code null}
+ * @param customStatus the serialized custom status, or {@code null}
+ * @param failureDetails the failure details when the orchestration failed, or {@code null}
+ * @param executionId the execution ID, or {@code null}
+ * @param parentInstanceId the parent instance ID, or {@code null}
+ * @param tags the orchestration tags, or {@code null}
+ */
+ public OrchestrationState(
+ String instanceId,
+ String name,
+ @Nullable String version,
+ OrchestrationRuntimeStatus runtimeStatus,
+ @Nullable Instant scheduledStartTime,
+ @Nullable Instant createdTime,
+ @Nullable Instant lastUpdatedTime,
+ @Nullable Instant completedTime,
+ @Nullable String input,
+ @Nullable String output,
+ @Nullable String customStatus,
+ @Nullable FailureDetails failureDetails,
+ @Nullable String executionId,
+ @Nullable String parentInstanceId,
+ @Nullable Map tags) {
+ this.instanceId = instanceId;
+ this.name = name;
+ this.version = version;
+ this.runtimeStatus = runtimeStatus;
+ this.scheduledStartTime = scheduledStartTime;
+ this.createdTime = createdTime;
+ this.lastUpdatedTime = lastUpdatedTime;
+ this.completedTime = completedTime;
+ this.input = input;
+ this.output = output;
+ this.customStatus = customStatus;
+ this.failureDetails = failureDetails;
+ this.executionId = executionId;
+ this.parentInstanceId = parentInstanceId;
+ this.tags = tags == null ? null : Collections.unmodifiableMap(new HashMap<>(tags));
+ }
+
+ /** @return the orchestration instance ID. */
+ public String getInstanceId() {
+ return this.instanceId;
+ }
+
+ /** @return the orchestration name. */
+ public String getName() {
+ return this.name;
+ }
+
+ /** @return the orchestration version, or {@code null} if not set. */
+ @Nullable
+ public String getVersion() {
+ return this.version;
+ }
+
+ /** @return the runtime status of the orchestration. */
+ public OrchestrationRuntimeStatus getRuntimeStatus() {
+ return this.runtimeStatus;
+ }
+
+ /** @return the scheduled start time, or {@code null} if not set. */
+ @Nullable
+ public Instant getScheduledStartTime() {
+ return this.scheduledStartTime;
+ }
+
+ /** @return the creation time, or {@code null} if not set. */
+ @Nullable
+ public Instant getCreatedTime() {
+ return this.createdTime;
+ }
+
+ /** @return the last-updated time, or {@code null} if not set. */
+ @Nullable
+ public Instant getLastUpdatedTime() {
+ return this.lastUpdatedTime;
+ }
+
+ /** @return the completion time, or {@code null} if not set. */
+ @Nullable
+ public Instant getCompletedTime() {
+ return this.completedTime;
+ }
+
+ /** @return the serialized orchestration input, or {@code null} if not set. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+
+ /** @return the serialized orchestration output, or {@code null} if not set. */
+ @Nullable
+ public String getOutput() {
+ return this.output;
+ }
+
+ /** @return the serialized custom status, or {@code null} if not set. */
+ @Nullable
+ public String getCustomStatus() {
+ return this.customStatus;
+ }
+
+ /** @return the failure details when the orchestration failed, or {@code null} otherwise. */
+ @Nullable
+ public FailureDetails getFailureDetails() {
+ return this.failureDetails;
+ }
+
+ /** @return the execution ID, or {@code null} if not set. */
+ @Nullable
+ public String getExecutionId() {
+ return this.executionId;
+ }
+
+ /** @return the parent instance ID, or {@code null} if not set. */
+ @Nullable
+ public String getParentInstanceId() {
+ return this.parentInstanceId;
+ }
+
+ /** @return an unmodifiable view of the orchestration tags, or {@code null} if not set. */
+ @Nullable
+ public Map getTags() {
+ return this.tags;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/OrchestratorCompletedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/OrchestratorCompletedEvent.java
new file mode 100644
index 00000000..d99a1efe
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/OrchestratorCompletedEvent.java
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import java.time.Instant;
+
+/**
+ * History event recorded at the end of each orchestration replay/episode. Carries no payload.
+ */
+public final class OrchestratorCompletedEvent extends HistoryEvent {
+ /**
+ * Creates a new {@code OrchestratorCompletedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ */
+ public OrchestratorCompletedEvent(int eventId, Instant timestamp) {
+ super(eventId, timestamp);
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/OrchestratorStartedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/OrchestratorStartedEvent.java
new file mode 100644
index 00000000..c470acaa
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/OrchestratorStartedEvent.java
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import java.time.Instant;
+
+/**
+ * History event recorded at the start of each orchestration replay/episode. Carries no payload.
+ */
+public final class OrchestratorStartedEvent extends HistoryEvent {
+ /**
+ * Creates a new {@code OrchestratorStartedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ */
+ public OrchestratorStartedEvent(int eventId, Instant timestamp) {
+ super(eventId, timestamp);
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/ParentInstanceInfo.java b/client/src/main/java/com/microsoft/durabletask/history/ParentInstanceInfo.java
new file mode 100644
index 00000000..ef90d651
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/ParentInstanceInfo.java
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+
+/**
+ * Information about the parent orchestration that created a sub-orchestration.
+ */
+public final class ParentInstanceInfo {
+ private final int taskScheduledId;
+ private final String name;
+ private final String version;
+ private final OrchestrationInstance orchestrationInstance;
+
+ /**
+ * Creates a new {@code ParentInstanceInfo}.
+ *
+ * @param taskScheduledId the task scheduled ID of the sub-orchestration in the parent's history
+ * @param name the parent orchestrator name, or {@code null}
+ * @param version the parent orchestrator version, or {@code null}
+ * @param orchestrationInstance the parent orchestration instance, or {@code null}
+ */
+ public ParentInstanceInfo(
+ int taskScheduledId,
+ @Nullable String name,
+ @Nullable String version,
+ @Nullable OrchestrationInstance orchestrationInstance) {
+ this.taskScheduledId = taskScheduledId;
+ this.name = name;
+ this.version = version;
+ this.orchestrationInstance = orchestrationInstance;
+ }
+
+ /** @return the task scheduled ID of the sub-orchestration in the parent's history. */
+ public int getTaskScheduledId() {
+ return this.taskScheduledId;
+ }
+
+ /** @return the parent orchestrator name, or {@code null} if not set. */
+ @Nullable
+ public String getName() {
+ return this.name;
+ }
+
+ /** @return the parent orchestrator version, or {@code null} if not set. */
+ @Nullable
+ public String getVersion() {
+ return this.version;
+ }
+
+ /** @return the parent orchestration instance, or {@code null} if not set. */
+ @Nullable
+ public OrchestrationInstance getOrchestrationInstance() {
+ return this.orchestrationInstance;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceCompletedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceCompletedEvent.java
new file mode 100644
index 00000000..d55f0fe2
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceCompletedEvent.java
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when a sub-orchestration instance completes successfully.
+ */
+public final class SubOrchestrationInstanceCompletedEvent extends HistoryEvent {
+ private final int taskScheduledId;
+ private final String result;
+
+ /**
+ * Creates a new {@code SubOrchestrationInstanceCompletedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param taskScheduledId the event ID of the corresponding {@link SubOrchestrationInstanceCreatedEvent}
+ * @param result the serialized sub-orchestration result, or {@code null}
+ */
+ public SubOrchestrationInstanceCompletedEvent(
+ int eventId, Instant timestamp, int taskScheduledId, @Nullable String result) {
+ super(eventId, timestamp);
+ this.taskScheduledId = taskScheduledId;
+ this.result = result;
+ }
+
+ /** @return the event ID of the corresponding {@link SubOrchestrationInstanceCreatedEvent}. */
+ public int getTaskScheduledId() {
+ return this.taskScheduledId;
+ }
+
+ /** @return the serialized sub-orchestration result, or {@code null} if none. */
+ @Nullable
+ public String getResult() {
+ return this.result;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceCreatedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceCreatedEvent.java
new file mode 100644
index 00000000..0eb401fe
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceCreatedEvent.java
@@ -0,0 +1,84 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * History event recorded when a sub-orchestration instance is created by an orchestration.
+ */
+public final class SubOrchestrationInstanceCreatedEvent extends HistoryEvent {
+ private final String instanceId;
+ private final String name;
+ private final String version;
+ private final String input;
+ private final TraceContext parentTraceContext;
+ private final Map tags;
+
+ /**
+ * Creates a new {@code SubOrchestrationInstanceCreatedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param instanceId the instance ID assigned to the sub-orchestration
+ * @param name the name of the sub-orchestrator
+ * @param version the sub-orchestrator version, or {@code null}
+ * @param input the serialized sub-orchestration input, or {@code null}
+ * @param parentTraceContext the parent distributed-tracing context, or {@code null}
+ * @param tags the sub-orchestration tags, or {@code null}
+ */
+ public SubOrchestrationInstanceCreatedEvent(
+ int eventId,
+ Instant timestamp,
+ String instanceId,
+ String name,
+ @Nullable String version,
+ @Nullable String input,
+ @Nullable TraceContext parentTraceContext,
+ @Nullable Map tags) {
+ super(eventId, timestamp);
+ this.instanceId = instanceId;
+ this.name = name;
+ this.version = version;
+ this.input = input;
+ this.parentTraceContext = parentTraceContext;
+ this.tags = tags != null ? Collections.unmodifiableMap(new HashMap<>(tags)) : Collections.emptyMap();
+ }
+
+ /** @return the instance ID assigned to the sub-orchestration. */
+ public String getInstanceId() {
+ return this.instanceId;
+ }
+
+ /** @return the name of the sub-orchestrator. */
+ public String getName() {
+ return this.name;
+ }
+
+ /** @return the sub-orchestrator version, or {@code null} if not set. */
+ @Nullable
+ public String getVersion() {
+ return this.version;
+ }
+
+ /** @return the serialized sub-orchestration input, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+
+ /** @return the distributed-tracing context of the parent, or {@code null} if not set. */
+ @Nullable
+ public TraceContext getParentTraceContext() {
+ return this.parentTraceContext;
+ }
+
+ /** @return the sub-orchestration tags (never {@code null}; empty when none). */
+ public Map getTags() {
+ return this.tags;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceFailedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceFailedEvent.java
new file mode 100644
index 00000000..bb3a2b95
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceFailedEvent.java
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import com.microsoft.durabletask.FailureDetails;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when a sub-orchestration instance fails.
+ */
+public final class SubOrchestrationInstanceFailedEvent extends HistoryEvent {
+ private final int taskScheduledId;
+ private final FailureDetails failureDetails;
+
+ /**
+ * Creates a new {@code SubOrchestrationInstanceFailedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param taskScheduledId the event ID of the corresponding {@link SubOrchestrationInstanceCreatedEvent}
+ * @param failureDetails the failure details, or {@code null}
+ */
+ public SubOrchestrationInstanceFailedEvent(
+ int eventId, Instant timestamp, int taskScheduledId, @Nullable FailureDetails failureDetails) {
+ super(eventId, timestamp);
+ this.taskScheduledId = taskScheduledId;
+ this.failureDetails = failureDetails;
+ }
+
+ /** @return the event ID of the corresponding {@link SubOrchestrationInstanceCreatedEvent}. */
+ public int getTaskScheduledId() {
+ return this.taskScheduledId;
+ }
+
+ /** @return the failure details, or {@code null} if not available. */
+ @Nullable
+ public FailureDetails getFailureDetails() {
+ return this.failureDetails;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/TaskCompletedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/TaskCompletedEvent.java
new file mode 100644
index 00000000..c2b6ab2f
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/TaskCompletedEvent.java
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when a scheduled activity task completes successfully.
+ */
+public final class TaskCompletedEvent extends HistoryEvent {
+ private final int taskScheduledId;
+ private final String result;
+
+ /**
+ * Creates a new {@code TaskCompletedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param taskScheduledId the event ID of the corresponding {@link TaskScheduledEvent}
+ * @param result the serialized activity result, or {@code null}
+ */
+ public TaskCompletedEvent(int eventId, Instant timestamp, int taskScheduledId, @Nullable String result) {
+ super(eventId, timestamp);
+ this.taskScheduledId = taskScheduledId;
+ this.result = result;
+ }
+
+ /** @return the event ID of the corresponding {@link TaskScheduledEvent}. */
+ public int getTaskScheduledId() {
+ return this.taskScheduledId;
+ }
+
+ /** @return the serialized activity result, or {@code null} if none. */
+ @Nullable
+ public String getResult() {
+ return this.result;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/TaskFailedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/TaskFailedEvent.java
new file mode 100644
index 00000000..efa206e0
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/TaskFailedEvent.java
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import com.microsoft.durabletask.FailureDetails;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * History event recorded when a scheduled activity task fails.
+ */
+public final class TaskFailedEvent extends HistoryEvent {
+ private final int taskScheduledId;
+ private final FailureDetails failureDetails;
+
+ /**
+ * Creates a new {@code TaskFailedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param taskScheduledId the event ID of the corresponding {@link TaskScheduledEvent}
+ * @param failureDetails the failure details, or {@code null}
+ */
+ public TaskFailedEvent(
+ int eventId, Instant timestamp, int taskScheduledId, @Nullable FailureDetails failureDetails) {
+ super(eventId, timestamp);
+ this.taskScheduledId = taskScheduledId;
+ this.failureDetails = failureDetails;
+ }
+
+ /** @return the event ID of the corresponding {@link TaskScheduledEvent}. */
+ public int getTaskScheduledId() {
+ return this.taskScheduledId;
+ }
+
+ /** @return the failure details, or {@code null} if not available. */
+ @Nullable
+ public FailureDetails getFailureDetails() {
+ return this.failureDetails;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/TaskScheduledEvent.java b/client/src/main/java/com/microsoft/durabletask/history/TaskScheduledEvent.java
new file mode 100644
index 00000000..47ec0bac
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/TaskScheduledEvent.java
@@ -0,0 +1,75 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * History event recorded when an activity task is scheduled by an orchestration.
+ */
+public final class TaskScheduledEvent extends HistoryEvent {
+ private final String name;
+ private final String version;
+ private final String input;
+ private final TraceContext parentTraceContext;
+ private final Map tags;
+
+ /**
+ * Creates a new {@code TaskScheduledEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param name the name of the scheduled activity
+ * @param version the activity version, or {@code null}
+ * @param input the serialized activity input, or {@code null}
+ * @param parentTraceContext the parent distributed-tracing context, or {@code null}
+ * @param tags the activity tags, or {@code null}
+ */
+ public TaskScheduledEvent(
+ int eventId,
+ Instant timestamp,
+ String name,
+ @Nullable String version,
+ @Nullable String input,
+ @Nullable TraceContext parentTraceContext,
+ @Nullable Map tags) {
+ super(eventId, timestamp);
+ this.name = name;
+ this.version = version;
+ this.input = input;
+ this.parentTraceContext = parentTraceContext;
+ this.tags = tags != null ? Collections.unmodifiableMap(new HashMap<>(tags)) : Collections.emptyMap();
+ }
+
+ /** @return the name of the scheduled activity. */
+ public String getName() {
+ return this.name;
+ }
+
+ /** @return the activity version, or {@code null} if not set. */
+ @Nullable
+ public String getVersion() {
+ return this.version;
+ }
+
+ /** @return the serialized activity input, or {@code null} if none. */
+ @Nullable
+ public String getInput() {
+ return this.input;
+ }
+
+ /** @return the distributed-tracing context of the parent, or {@code null} if not set. */
+ @Nullable
+ public TraceContext getParentTraceContext() {
+ return this.parentTraceContext;
+ }
+
+ /** @return the activity tags (never {@code null}; empty when none). */
+ public Map getTags() {
+ return this.tags;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/TimerCreatedEvent.java b/client/src/main/java/com/microsoft/durabletask/history/TimerCreatedEvent.java
new file mode 100644
index 00000000..4f98c0bf
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/TimerCreatedEvent.java
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import java.time.Instant;
+
+/**
+ * History event recorded when a durable timer is created by an orchestration.
+ */
+public final class TimerCreatedEvent extends HistoryEvent {
+ private final Instant fireAt;
+
+ /**
+ * Creates a new {@code TimerCreatedEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param fireAt the time at which the timer is scheduled to fire
+ */
+ public TimerCreatedEvent(int eventId, Instant timestamp, Instant fireAt) {
+ super(eventId, timestamp);
+ this.fireAt = fireAt;
+ }
+
+ /** @return the time at which the timer is scheduled to fire. */
+ public Instant getFireAt() {
+ return this.fireAt;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/TimerFiredEvent.java b/client/src/main/java/com/microsoft/durabletask/history/TimerFiredEvent.java
new file mode 100644
index 00000000..50fa9238
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/TimerFiredEvent.java
@@ -0,0 +1,37 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import java.time.Instant;
+
+/**
+ * History event recorded when a durable timer fires.
+ */
+public final class TimerFiredEvent extends HistoryEvent {
+ private final Instant fireAt;
+ private final int timerId;
+
+ /**
+ * Creates a new {@code TimerFiredEvent}.
+ *
+ * @param eventId the event sequence ID
+ * @param timestamp the event timestamp
+ * @param fireAt the time at which the timer was scheduled to fire
+ * @param timerId the event ID of the corresponding {@link TimerCreatedEvent}
+ */
+ public TimerFiredEvent(int eventId, Instant timestamp, Instant fireAt, int timerId) {
+ super(eventId, timestamp);
+ this.fireAt = fireAt;
+ this.timerId = timerId;
+ }
+
+ /** @return the time at which the timer was scheduled to fire. */
+ public Instant getFireAt() {
+ return this.fireAt;
+ }
+
+ /** @return the event ID of the corresponding {@link TimerCreatedEvent}. */
+ public int getTimerId() {
+ return this.timerId;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/history/TraceContext.java b/client/src/main/java/com/microsoft/durabletask/history/TraceContext.java
new file mode 100644
index 00000000..566c7dc9
--- /dev/null
+++ b/client/src/main/java/com/microsoft/durabletask/history/TraceContext.java
@@ -0,0 +1,35 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.history;
+
+import javax.annotation.Nullable;
+
+/**
+ * Distributed-tracing context (W3C Trace Context) associated with a history event.
+ */
+public final class TraceContext {
+ private final String traceParent;
+ private final String traceState;
+
+ /**
+ * Creates a new {@code TraceContext}.
+ *
+ * @param traceParent the W3C {@code traceparent} value
+ * @param traceState the W3C {@code tracestate} value, or {@code null}
+ */
+ public TraceContext(String traceParent, @Nullable String traceState) {
+ this.traceParent = traceParent;
+ this.traceState = traceState;
+ }
+
+ /** @return the W3C {@code traceparent} value. */
+ public String getTraceParent() {
+ return this.traceParent;
+ }
+
+ /** @return the W3C {@code tracestate} value, or {@code null} if not set. */
+ @Nullable
+ public String getTraceState() {
+ return this.traceState;
+ }
+}
diff --git a/client/src/test/java/com/microsoft/durabletask/HistoryEventConverterTest.java b/client/src/test/java/com/microsoft/durabletask/HistoryEventConverterTest.java
new file mode 100644
index 00000000..e1a86a88
--- /dev/null
+++ b/client/src/test/java/com/microsoft/durabletask/HistoryEventConverterTest.java
@@ -0,0 +1,608 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.durabletask;
+
+import com.google.protobuf.StringValue;
+import com.google.protobuf.Timestamp;
+import com.microsoft.durabletask.history.ContinueAsNewEvent;
+import com.microsoft.durabletask.history.EntityLockGrantedEvent;
+import com.microsoft.durabletask.history.EntityLockRequestedEvent;
+import com.microsoft.durabletask.history.EntityOperationCalledEvent;
+import com.microsoft.durabletask.history.EntityOperationCompletedEvent;
+import com.microsoft.durabletask.history.EntityOperationFailedEvent;
+import com.microsoft.durabletask.history.EntityOperationSignaledEvent;
+import com.microsoft.durabletask.history.EntityUnlockSentEvent;
+import com.microsoft.durabletask.history.EventRaisedEvent;
+import com.microsoft.durabletask.history.EventSentEvent;
+import com.microsoft.durabletask.history.ExecutionCompletedEvent;
+import com.microsoft.durabletask.history.ExecutionResumedEvent;
+import com.microsoft.durabletask.history.ExecutionRewoundEvent;
+import com.microsoft.durabletask.history.ExecutionStartedEvent;
+import com.microsoft.durabletask.history.ExecutionSuspendedEvent;
+import com.microsoft.durabletask.history.ExecutionTerminatedEvent;
+import com.microsoft.durabletask.history.GenericEvent;
+import com.microsoft.durabletask.history.HistoryEvent;
+import com.microsoft.durabletask.history.HistoryStateEvent;
+import com.microsoft.durabletask.history.OrchestratorCompletedEvent;
+import com.microsoft.durabletask.history.OrchestratorStartedEvent;
+import com.microsoft.durabletask.history.OrchestrationState;
+import com.microsoft.durabletask.history.SubOrchestrationInstanceCreatedEvent;
+import com.microsoft.durabletask.history.SubOrchestrationInstanceCompletedEvent;
+import com.microsoft.durabletask.history.SubOrchestrationInstanceFailedEvent;
+import com.microsoft.durabletask.history.TaskCompletedEvent;
+import com.microsoft.durabletask.history.TaskFailedEvent;
+import com.microsoft.durabletask.history.TaskScheduledEvent;
+import com.microsoft.durabletask.history.TimerCreatedEvent;
+import com.microsoft.durabletask.history.TimerFiredEvent;
+import com.microsoft.durabletask.implementation.protobuf.OrchestratorService;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link HistoryEventConverter}, which maps protobuf history events to the public
+ * {@code com.microsoft.durabletask.history} domain model.
+ */
+public class HistoryEventConverterTest {
+
+ private static final long EPOCH_SECONDS = 1_700_000_000L;
+ private static final Instant EXPECTED_TIMESTAMP = Instant.ofEpochSecond(EPOCH_SECONDS);
+
+ private static OrchestratorService.HistoryEvent.Builder baseEvent(int eventId) {
+ return OrchestratorService.HistoryEvent.newBuilder()
+ .setEventId(eventId)
+ .setTimestamp(Timestamp.newBuilder().setSeconds(EPOCH_SECONDS).build());
+ }
+
+ @Test
+ void convertsExecutionStarted() {
+ OrchestratorService.HistoryEvent proto = baseEvent(1)
+ .setExecutionStarted(OrchestratorService.ExecutionStartedEvent.newBuilder()
+ .setName("MyOrchestration")
+ .setVersion(StringValue.of("2.0"))
+ .setInput(StringValue.of("\"hello\""))
+ .setOrchestrationInstance(OrchestratorService.OrchestrationInstance.newBuilder()
+ .setInstanceId("inst-1")
+ .setExecutionId(StringValue.of("exec-1"))))
+ .build();
+
+ HistoryEvent event = HistoryEventConverter.fromProto(proto);
+
+ ExecutionStartedEvent started = assertInstanceOf(ExecutionStartedEvent.class, event);
+ assertEquals(1, started.getEventId());
+ assertEquals(EXPECTED_TIMESTAMP, started.getTimestamp());
+ assertEquals("MyOrchestration", started.getName());
+ assertEquals("2.0", started.getVersion());
+ assertEquals("\"hello\"", started.getInput());
+ assertNotNull(started.getOrchestrationInstance());
+ assertEquals("inst-1", started.getOrchestrationInstance().getInstanceId());
+ assertEquals("exec-1", started.getOrchestrationInstance().getExecutionId());
+ }
+
+ @Test
+ void convertsExecutionCompletedWithResult() {
+ OrchestratorService.HistoryEvent proto = baseEvent(2)
+ .setExecutionCompleted(OrchestratorService.ExecutionCompletedEvent.newBuilder()
+ .setOrchestrationStatus(OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED)
+ .setResult(StringValue.of("\"done\"")))
+ .build();
+
+ HistoryEvent event = HistoryEventConverter.fromProto(proto);
+
+ ExecutionCompletedEvent completed = assertInstanceOf(ExecutionCompletedEvent.class, event);
+ assertEquals(OrchestrationRuntimeStatus.COMPLETED, completed.getOrchestrationStatus());
+ assertEquals("\"done\"", completed.getResult());
+ assertNull(completed.getFailureDetails());
+ }
+
+ @Test
+ void convertsExecutionCompletedWithFailureDetails() {
+ OrchestratorService.HistoryEvent proto = baseEvent(3)
+ .setExecutionCompleted(OrchestratorService.ExecutionCompletedEvent.newBuilder()
+ .setOrchestrationStatus(OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED)
+ .setFailureDetails(OrchestratorService.TaskFailureDetails.newBuilder()
+ .setErrorType("java.lang.RuntimeException")
+ .setErrorMessage("boom")))
+ .build();
+
+ HistoryEvent event = HistoryEventConverter.fromProto(proto);
+
+ ExecutionCompletedEvent completed = assertInstanceOf(ExecutionCompletedEvent.class, event);
+ assertEquals(OrchestrationRuntimeStatus.FAILED, completed.getOrchestrationStatus());
+ assertNotNull(completed.getFailureDetails());
+ assertEquals("java.lang.RuntimeException", completed.getFailureDetails().getErrorType());
+ assertEquals("boom", completed.getFailureDetails().getErrorMessage());
+ }
+
+ @Test
+ void convertsTaskCompleted() {
+ OrchestratorService.HistoryEvent proto = baseEvent(4)
+ .setTaskCompleted(OrchestratorService.TaskCompletedEvent.newBuilder()
+ .setTaskScheduledId(7)
+ .setResult(StringValue.of("42")))
+ .build();
+
+ HistoryEvent event = HistoryEventConverter.fromProto(proto);
+
+ TaskCompletedEvent completed = assertInstanceOf(TaskCompletedEvent.class, event);
+ assertEquals(7, completed.getTaskScheduledId());
+ assertEquals("42", completed.getResult());
+ }
+
+ @Test
+ void convertsTaskFailedWithFailureDetails() {
+ OrchestratorService.HistoryEvent proto = baseEvent(5)
+ .setTaskFailed(OrchestratorService.TaskFailedEvent.newBuilder()
+ .setTaskScheduledId(9)
+ .setFailureDetails(OrchestratorService.TaskFailureDetails.newBuilder()
+ .setErrorType("java.io.IOException")
+ .setErrorMessage("disk full")))
+ .build();
+
+ HistoryEvent event = HistoryEventConverter.fromProto(proto);
+
+ TaskFailedEvent failed = assertInstanceOf(TaskFailedEvent.class, event);
+ assertEquals(9, failed.getTaskScheduledId());
+ assertNotNull(failed.getFailureDetails());
+ assertEquals("java.io.IOException", failed.getFailureDetails().getErrorType());
+ }
+
+ @Test
+ void convertsSubOrchestrationCompleted() {
+ OrchestratorService.HistoryEvent proto = baseEvent(6)
+ .setSubOrchestrationInstanceCompleted(
+ OrchestratorService.SubOrchestrationInstanceCompletedEvent.newBuilder()
+ .setTaskScheduledId(3)
+ .setResult(StringValue.of("\"child-result\"")))
+ .build();
+
+ HistoryEvent event = HistoryEventConverter.fromProto(proto);
+
+ SubOrchestrationInstanceCompletedEvent completed =
+ assertInstanceOf(SubOrchestrationInstanceCompletedEvent.class, event);
+ assertEquals(3, completed.getTaskScheduledId());
+ assertEquals("\"child-result\"", completed.getResult());
+ }
+
+ @Test
+ void convertsTimerFired() {
+ Instant fireAt = Instant.ofEpochSecond(EPOCH_SECONDS + 60);
+ OrchestratorService.HistoryEvent proto = baseEvent(7)
+ .setTimerFired(OrchestratorService.TimerFiredEvent.newBuilder()
+ .setTimerId(11)
+ .setFireAt(Timestamp.newBuilder().setSeconds(fireAt.getEpochSecond()).build()))
+ .build();
+
+ HistoryEvent event = HistoryEventConverter.fromProto(proto);
+
+ TimerFiredEvent fired = assertInstanceOf(TimerFiredEvent.class, event);
+ assertEquals(11, fired.getTimerId());
+ assertEquals(fireAt, fired.getFireAt());
+ }
+
+ @Test
+ void convertsGenericEvent() {
+ OrchestratorService.HistoryEvent proto = baseEvent(8)
+ .setGenericEvent(OrchestratorService.GenericEvent.newBuilder()
+ .setData(StringValue.of("payload")))
+ .build();
+
+ HistoryEvent event = HistoryEventConverter.fromProto(proto);
+
+ GenericEvent generic = assertInstanceOf(GenericEvent.class, event);
+ assertEquals("payload", generic.getData());
+ }
+
+ @Test
+ void convertsHistoryStateExposesFullOrchestrationState() {
+ Instant created = Instant.ofEpochSecond(EPOCH_SECONDS - 100);
+ Instant lastUpdated = Instant.ofEpochSecond(EPOCH_SECONDS - 50);
+ OrchestratorService.HistoryEvent proto = baseEvent(9)
+ .setHistoryState(OrchestratorService.HistoryStateEvent.newBuilder()
+ .setOrchestrationState(OrchestratorService.OrchestrationState.newBuilder()
+ .setInstanceId("inst-9")
+ .setName("MyOrchestration")
+ .setVersion(StringValue.of("1.5"))
+ .setOrchestrationStatus(
+ OrchestratorService.OrchestrationStatus.ORCHESTRATION_STATUS_RUNNING)
+ .setCreatedTimestamp(Timestamp.newBuilder().setSeconds(created.getEpochSecond()))
+ .setLastUpdatedTimestamp(
+ Timestamp.newBuilder().setSeconds(lastUpdated.getEpochSecond()))
+ .setInput(StringValue.of("\"in\""))
+ .setCustomStatus(StringValue.of("\"working\""))
+ .putTags("env", "prod")))
+ .build();
+
+ HistoryEvent event = HistoryEventConverter.fromProto(proto);
+
+ HistoryStateEvent stateEvent = assertInstanceOf(HistoryStateEvent.class, event);
+ OrchestrationState state = stateEvent.getState();
+ assertNotNull(state);
+ assertEquals("inst-9", state.getInstanceId());
+ assertEquals("MyOrchestration", state.getName());
+ assertEquals("1.5", state.getVersion());
+ assertEquals(OrchestrationRuntimeStatus.RUNNING, state.getRuntimeStatus());
+ assertEquals(created, state.getCreatedTime());
+ assertEquals(lastUpdated, state.getLastUpdatedTime());
+ assertEquals("\"in\"", state.getInput());
+ assertEquals("\"working\"", state.getCustomStatus());
+ assertNotNull(state.getTags());
+ assertEquals("prod", state.getTags().get("env"));
+ }
+
+ @Test
+ void convertsExecutionTerminated() {
+ OrchestratorService.HistoryEvent proto = baseEvent(11)
+ .setExecutionTerminated(OrchestratorService.ExecutionTerminatedEvent.newBuilder()
+ .setInput(StringValue.of("\"termination reason\""))
+ .setRecurse(true))
+ .build();
+
+ ExecutionTerminatedEvent terminated =
+ assertInstanceOf(ExecutionTerminatedEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("\"termination reason\"", terminated.getInput());
+ assertTrue(terminated.isRecurse());
+ }
+
+ @Test
+ void convertsTaskScheduled() {
+ OrchestratorService.HistoryEvent proto = baseEvent(12)
+ .setTaskScheduled(OrchestratorService.TaskScheduledEvent.newBuilder()
+ .setName("SendEmail")
+ .setVersion(StringValue.of("v2"))
+ .setInput(StringValue.of("\"message\""))
+ .setParentTraceContext(OrchestratorService.TraceContext.newBuilder()
+ .setTraceParent("trace-parent")
+ .setTraceState(StringValue.of("trace-state")))
+ .putTags("operation", "email"))
+ .build();
+
+ TaskScheduledEvent scheduled =
+ assertInstanceOf(TaskScheduledEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("SendEmail", scheduled.getName());
+ assertEquals("v2", scheduled.getVersion());
+ assertEquals("\"message\"", scheduled.getInput());
+ assertNotNull(scheduled.getParentTraceContext());
+ assertEquals("trace-parent", scheduled.getParentTraceContext().getTraceParent());
+ assertEquals("trace-state", scheduled.getParentTraceContext().getTraceState());
+ assertEquals("email", scheduled.getTags().get("operation"));
+ }
+
+ @Test
+ void convertsSubOrchestrationInstanceCreated() {
+ OrchestratorService.HistoryEvent proto = baseEvent(13)
+ .setSubOrchestrationInstanceCreated(
+ OrchestratorService.SubOrchestrationInstanceCreatedEvent.newBuilder()
+ .setInstanceId("child-instance")
+ .setName("ChildOrchestrator")
+ .setVersion(StringValue.of("v3"))
+ .setInput(StringValue.of("\"child input\""))
+ .setParentTraceContext(OrchestratorService.TraceContext.newBuilder()
+ .setTraceParent("child-trace-parent")
+ .setTraceState(StringValue.of("child-trace-state")))
+ .putTags("source", "parent"))
+ .build();
+
+ SubOrchestrationInstanceCreatedEvent created = assertInstanceOf(
+ SubOrchestrationInstanceCreatedEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("child-instance", created.getInstanceId());
+ assertEquals("ChildOrchestrator", created.getName());
+ assertEquals("v3", created.getVersion());
+ assertEquals("\"child input\"", created.getInput());
+ assertNotNull(created.getParentTraceContext());
+ assertEquals("child-trace-parent", created.getParentTraceContext().getTraceParent());
+ assertEquals("child-trace-state", created.getParentTraceContext().getTraceState());
+ assertEquals("parent", created.getTags().get("source"));
+ }
+
+ @Test
+ void convertsSubOrchestrationInstanceFailed() {
+ OrchestratorService.HistoryEvent proto = baseEvent(14)
+ .setSubOrchestrationInstanceFailed(
+ OrchestratorService.SubOrchestrationInstanceFailedEvent.newBuilder()
+ .setTaskScheduledId(23)
+ .setFailureDetails(OrchestratorService.TaskFailureDetails.newBuilder()
+ .setErrorType("java.lang.IllegalStateException")
+ .setErrorMessage("child failed")))
+ .build();
+
+ SubOrchestrationInstanceFailedEvent failed = assertInstanceOf(
+ SubOrchestrationInstanceFailedEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals(23, failed.getTaskScheduledId());
+ assertNotNull(failed.getFailureDetails());
+ assertEquals("java.lang.IllegalStateException", failed.getFailureDetails().getErrorType());
+ assertEquals("child failed", failed.getFailureDetails().getErrorMessage());
+ }
+
+ @Test
+ void convertsTimerCreated() {
+ Instant fireAt = Instant.ofEpochSecond(EPOCH_SECONDS + 120);
+ OrchestratorService.HistoryEvent proto = baseEvent(15)
+ .setTimerCreated(OrchestratorService.TimerCreatedEvent.newBuilder()
+ .setFireAt(Timestamp.newBuilder().setSeconds(fireAt.getEpochSecond())))
+ .build();
+
+ TimerCreatedEvent created = assertInstanceOf(TimerCreatedEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals(fireAt, created.getFireAt());
+ }
+
+ @Test
+ void convertsOrchestratorStartedAndCompleted() {
+ OrchestratorService.HistoryEvent startedProto = baseEvent(16)
+ .setOrchestratorStarted(OrchestratorService.OrchestratorStartedEvent.newBuilder())
+ .build();
+ OrchestratorService.HistoryEvent completedProto = baseEvent(17)
+ .setOrchestratorCompleted(OrchestratorService.OrchestratorCompletedEvent.newBuilder())
+ .build();
+
+ OrchestratorStartedEvent started =
+ assertInstanceOf(OrchestratorStartedEvent.class, HistoryEventConverter.fromProto(startedProto));
+ OrchestratorCompletedEvent completed =
+ assertInstanceOf(OrchestratorCompletedEvent.class, HistoryEventConverter.fromProto(completedProto));
+
+ assertEquals(16, started.getEventId());
+ assertEquals(EXPECTED_TIMESTAMP, started.getTimestamp());
+ assertEquals(17, completed.getEventId());
+ assertEquals(EXPECTED_TIMESTAMP, completed.getTimestamp());
+ }
+
+ @Test
+ void convertsEventSent() {
+ OrchestratorService.HistoryEvent proto = baseEvent(18)
+ .setEventSent(OrchestratorService.EventSentEvent.newBuilder()
+ .setInstanceId("target-instance")
+ .setName("ApprovalReceived")
+ .setInput(StringValue.of("true")))
+ .build();
+
+ EventSentEvent sent = assertInstanceOf(EventSentEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("target-instance", sent.getInstanceId());
+ assertEquals("ApprovalReceived", sent.getName());
+ assertEquals("true", sent.getInput());
+ }
+
+ @Test
+ void convertsEventRaised() {
+ OrchestratorService.HistoryEvent proto = baseEvent(19)
+ .setEventRaised(OrchestratorService.EventRaisedEvent.newBuilder()
+ .setName("ApprovalReceived")
+ .setInput(StringValue.of("true")))
+ .build();
+
+ EventRaisedEvent raised = assertInstanceOf(EventRaisedEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("ApprovalReceived", raised.getName());
+ assertEquals("true", raised.getInput());
+ }
+
+ @Test
+ void convertsContinueAsNew() {
+ OrchestratorService.HistoryEvent proto = baseEvent(20)
+ .setContinueAsNew(OrchestratorService.ContinueAsNewEvent.newBuilder()
+ .setInput(StringValue.of("\"next generation\"")))
+ .build();
+
+ ContinueAsNewEvent continued =
+ assertInstanceOf(ContinueAsNewEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("\"next generation\"", continued.getInput());
+ }
+
+ @Test
+ void convertsExecutionSuspendedAndResumed() {
+ OrchestratorService.HistoryEvent suspendedProto = baseEvent(21)
+ .setExecutionSuspended(OrchestratorService.ExecutionSuspendedEvent.newBuilder()
+ .setInput(StringValue.of("\"maintenance\"")))
+ .build();
+ OrchestratorService.HistoryEvent resumedProto = baseEvent(22)
+ .setExecutionResumed(OrchestratorService.ExecutionResumedEvent.newBuilder()
+ .setInput(StringValue.of("\"maintenance complete\"")))
+ .build();
+
+ ExecutionSuspendedEvent suspended =
+ assertInstanceOf(ExecutionSuspendedEvent.class, HistoryEventConverter.fromProto(suspendedProto));
+ ExecutionResumedEvent resumed =
+ assertInstanceOf(ExecutionResumedEvent.class, HistoryEventConverter.fromProto(resumedProto));
+
+ assertEquals("\"maintenance\"", suspended.getInput());
+ assertEquals("\"maintenance complete\"", resumed.getInput());
+ }
+
+ @Test
+ void convertsEntityOperationSignaled() {
+ Instant scheduledTime = Instant.ofEpochSecond(EPOCH_SECONDS + 180);
+ OrchestratorService.HistoryEvent proto = baseEvent(23)
+ .setEntityOperationSignaled(OrchestratorService.EntityOperationSignaledEvent.newBuilder()
+ .setRequestId("signal-request")
+ .setOperation("increment")
+ .setScheduledTime(Timestamp.newBuilder().setSeconds(scheduledTime.getEpochSecond()))
+ .setInput(StringValue.of("5"))
+ .setTargetInstanceId(StringValue.of("@counter@one")))
+ .build();
+
+ EntityOperationSignaledEvent signaled =
+ assertInstanceOf(EntityOperationSignaledEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("signal-request", signaled.getRequestId());
+ assertEquals("increment", signaled.getOperation());
+ assertEquals(scheduledTime, signaled.getScheduledTime());
+ assertEquals("5", signaled.getInput());
+ assertEquals("@counter@one", signaled.getTargetInstanceId());
+ }
+
+ @Test
+ void convertsEntityOperationCalled() {
+ Instant scheduledTime = Instant.ofEpochSecond(EPOCH_SECONDS + 240);
+ OrchestratorService.HistoryEvent proto = baseEvent(24)
+ .setEntityOperationCalled(OrchestratorService.EntityOperationCalledEvent.newBuilder()
+ .setRequestId("call-request")
+ .setOperation("get")
+ .setScheduledTime(Timestamp.newBuilder().setSeconds(scheduledTime.getEpochSecond()))
+ .setInput(StringValue.of("\"key\""))
+ .setParentInstanceId(StringValue.of("parent-instance"))
+ .setParentExecutionId(StringValue.of("parent-execution"))
+ .setTargetInstanceId(StringValue.of("@store@one")))
+ .build();
+
+ EntityOperationCalledEvent called =
+ assertInstanceOf(EntityOperationCalledEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("call-request", called.getRequestId());
+ assertEquals("get", called.getOperation());
+ assertEquals(scheduledTime, called.getScheduledTime());
+ assertEquals("\"key\"", called.getInput());
+ assertEquals("parent-instance", called.getParentInstanceId());
+ assertEquals("parent-execution", called.getParentExecutionId());
+ assertEquals("@store@one", called.getTargetInstanceId());
+ }
+
+ @Test
+ void convertsEntityOperationCompleted() {
+ OrchestratorService.HistoryEvent proto = baseEvent(25)
+ .setEntityOperationCompleted(OrchestratorService.EntityOperationCompletedEvent.newBuilder()
+ .setRequestId("completed-request")
+ .setOutput(StringValue.of("\"result\"")))
+ .build();
+
+ EntityOperationCompletedEvent completed =
+ assertInstanceOf(EntityOperationCompletedEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("completed-request", completed.getRequestId());
+ assertEquals("\"result\"", completed.getOutput());
+ }
+
+ @Test
+ void convertsEntityOperationFailed() {
+ OrchestratorService.HistoryEvent proto = baseEvent(26)
+ .setEntityOperationFailed(OrchestratorService.EntityOperationFailedEvent.newBuilder()
+ .setRequestId("failed-request")
+ .setFailureDetails(OrchestratorService.TaskFailureDetails.newBuilder()
+ .setErrorType("java.lang.IllegalArgumentException")
+ .setErrorMessage("invalid operation")))
+ .build();
+
+ EntityOperationFailedEvent failed =
+ assertInstanceOf(EntityOperationFailedEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("failed-request", failed.getRequestId());
+ assertNotNull(failed.getFailureDetails());
+ assertEquals("java.lang.IllegalArgumentException", failed.getFailureDetails().getErrorType());
+ assertEquals("invalid operation", failed.getFailureDetails().getErrorMessage());
+ }
+
+ @Test
+ void convertsEntityLockRequested() {
+ OrchestratorService.HistoryEvent proto = baseEvent(27)
+ .setEntityLockRequested(OrchestratorService.EntityLockRequestedEvent.newBuilder()
+ .setCriticalSectionId("critical-section")
+ .addAllLockSet(Arrays.asList("@account@one", "@account@two"))
+ .setPosition(1)
+ .setParentInstanceId(StringValue.of("parent-instance")))
+ .build();
+
+ EntityLockRequestedEvent requested =
+ assertInstanceOf(EntityLockRequestedEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("critical-section", requested.getCriticalSectionId());
+ assertEquals(Arrays.asList("@account@one", "@account@two"), requested.getLockSet());
+ assertEquals(1, requested.getPosition());
+ assertEquals("parent-instance", requested.getParentInstanceId());
+ }
+
+ @Test
+ void convertsEntityLockGranted() {
+ OrchestratorService.HistoryEvent proto = baseEvent(28)
+ .setEntityLockGranted(OrchestratorService.EntityLockGrantedEvent.newBuilder()
+ .setCriticalSectionId("critical-section"))
+ .build();
+
+ EntityLockGrantedEvent granted =
+ assertInstanceOf(EntityLockGrantedEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("critical-section", granted.getCriticalSectionId());
+ }
+
+ @Test
+ void convertsEntityUnlockSent() {
+ OrchestratorService.HistoryEvent proto = baseEvent(29)
+ .setEntityUnlockSent(OrchestratorService.EntityUnlockSentEvent.newBuilder()
+ .setCriticalSectionId("critical-section")
+ .setParentInstanceId(StringValue.of("parent-instance"))
+ .setTargetInstanceId(StringValue.of("@account@one")))
+ .build();
+
+ EntityUnlockSentEvent unlockSent =
+ assertInstanceOf(EntityUnlockSentEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("critical-section", unlockSent.getCriticalSectionId());
+ assertEquals("parent-instance", unlockSent.getParentInstanceId());
+ assertEquals("@account@one", unlockSent.getTargetInstanceId());
+ }
+
+ @Test
+ void convertsExecutionRewound() {
+ OrchestratorService.HistoryEvent proto = baseEvent(30)
+ .setExecutionRewound(OrchestratorService.ExecutionRewoundEvent.newBuilder()
+ .setReason(StringValue.of("retry after fix"))
+ .setParentExecutionId(StringValue.of("parent-execution"))
+ .setInstanceId(StringValue.of("child-instance"))
+ .setParentTraceContext(OrchestratorService.TraceContext.newBuilder()
+ .setTraceParent("rewind-trace-parent")
+ .setTraceState(StringValue.of("rewind-trace-state")))
+ .setName(StringValue.of("ChildOrchestrator"))
+ .setVersion(StringValue.of("v4"))
+ .setInput(StringValue.of("\"rewind input\""))
+ .setParentInstance(OrchestratorService.ParentInstanceInfo.newBuilder()
+ .setTaskScheduledId(31)
+ .setName(StringValue.of("ParentOrchestrator"))
+ .setVersion(StringValue.of("v1"))
+ .setOrchestrationInstance(OrchestratorService.OrchestrationInstance.newBuilder()
+ .setInstanceId("parent-instance")
+ .setExecutionId(StringValue.of("parent-execution"))))
+ .putTags("reason", "repair"))
+ .build();
+
+ ExecutionRewoundEvent rewound =
+ assertInstanceOf(ExecutionRewoundEvent.class, HistoryEventConverter.fromProto(proto));
+
+ assertEquals("retry after fix", rewound.getReason());
+ assertEquals("parent-execution", rewound.getParentExecutionId());
+ assertEquals("child-instance", rewound.getInstanceId());
+ assertNotNull(rewound.getParentTraceContext());
+ assertEquals("rewind-trace-parent", rewound.getParentTraceContext().getTraceParent());
+ assertEquals("rewind-trace-state", rewound.getParentTraceContext().getTraceState());
+ assertEquals("ChildOrchestrator", rewound.getName());
+ assertEquals("v4", rewound.getVersion());
+ assertEquals("\"rewind input\"", rewound.getInput());
+ assertNotNull(rewound.getParentInstance());
+ assertEquals(31, rewound.getParentInstance().getTaskScheduledId());
+ assertEquals("ParentOrchestrator", rewound.getParentInstance().getName());
+ assertEquals("v1", rewound.getParentInstance().getVersion());
+ assertNotNull(rewound.getParentInstance().getOrchestrationInstance());
+ assertEquals("parent-instance", rewound.getParentInstance().getOrchestrationInstance().getInstanceId());
+ assertEquals("parent-execution", rewound.getParentInstance().getOrchestrationInstance().getExecutionId());
+ assertEquals("repair", rewound.getTags().get("reason"));
+ }
+
+ @Test
+ void throwsWhenEventTypeNotSet() {
+ OrchestratorService.HistoryEvent proto = baseEvent(10).build();
+
+ assertThrows(IllegalArgumentException.class, () -> HistoryEventConverter.fromProto(proto));
+ }
+}
diff --git a/client/src/test/java/com/microsoft/durabletask/IntegrationTests.java b/client/src/test/java/com/microsoft/durabletask/IntegrationTests.java
index 59db0b7f..2c41b3fc 100644
--- a/client/src/test/java/com/microsoft/durabletask/IntegrationTests.java
+++ b/client/src/test/java/com/microsoft/durabletask/IntegrationTests.java
@@ -14,6 +14,9 @@
import java.util.stream.IntStream;
import java.util.stream.Stream;
+import com.microsoft.durabletask.history.ExecutionStartedEvent;
+import com.microsoft.durabletask.history.HistoryEvent;
+import com.microsoft.durabletask.history.TaskCompletedEvent;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -1202,6 +1205,73 @@ void waitForInstanceCompletionThrowsException() {
}
}
+ @Test
+ void listInstanceIdsByCompletionWindow() throws TimeoutException {
+ final String orchestratorName = "ListInstanceIdsExport";
+ final String plusOne = "PlusOne";
+
+ DurableTaskGrpcWorker worker = this.createWorkerBuilder()
+ .addOrchestrator(orchestratorName, ctx -> {
+ int value = ctx.getInput(int.class);
+ value = ctx.callActivity(plusOne, value, int.class).await();
+ ctx.complete(value);
+ })
+ .addActivity(plusOne, ctx -> ctx.getInput(int.class) + 1)
+ .buildAndStart();
+
+ DurableTaskClient client = this.createClientBuilder().build();
+ try (worker; client) {
+ Instant from = Instant.now().minus(Duration.ofMinutes(1));
+
+ String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0);
+ OrchestrationMetadata metadata = client.waitForInstanceCompletion(instanceId, defaultTimeout, false);
+ assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus());
+
+ ListInstanceIdsResult result = client.listInstanceIds(new ListInstanceIdsQuery()
+ .setCompletedTimeFrom(from)
+ .setRuntimeStatusList(Collections.singletonList(OrchestrationRuntimeStatus.COMPLETED))
+ .setPageSize(100));
+
+ assertNotNull(result);
+ assertNotNull(result.getInstanceIds());
+ assertTrue(result.getInstanceIds().contains(instanceId),
+ "Expected listInstanceIds to include the completed instance " + instanceId);
+ }
+ }
+
+ @Test
+ void getOrchestrationHistoryReturnsDomainEvents() throws TimeoutException {
+ final String orchestratorName = "StreamHistoryExport";
+ final String plusOne = "PlusOne";
+
+ DurableTaskGrpcWorker worker = this.createWorkerBuilder()
+ .addOrchestrator(orchestratorName, ctx -> {
+ int value = ctx.getInput(int.class);
+ value = ctx.callActivity(plusOne, value, int.class).await();
+ ctx.complete(value);
+ })
+ .addActivity(plusOne, ctx -> ctx.getInput(int.class) + 1)
+ .buildAndStart();
+
+ DurableTaskClient client = this.createClientBuilder().build();
+ try (worker; client) {
+ String instanceId = client.scheduleNewOrchestrationInstance(orchestratorName, 0);
+ OrchestrationMetadata metadata = client.waitForInstanceCompletion(instanceId, defaultTimeout, false);
+ assertEquals(OrchestrationRuntimeStatus.COMPLETED, metadata.getRuntimeStatus());
+
+ List history = client.getOrchestrationHistory(instanceId);
+
+ assertNotNull(history);
+ assertFalse(history.isEmpty(), "Expected a completed instance to have history events");
+ // A completed orchestration's history begins with an ExecutionStartedEvent.
+ assertTrue(history.stream().anyMatch(e -> e instanceof ExecutionStartedEvent),
+ "Expected history to contain an ExecutionStartedEvent");
+ // And it contains a successful activity completion.
+ assertTrue(history.stream().anyMatch(e -> e instanceof TaskCompletedEvent),
+ "Expected history to contain a TaskCompletedEvent");
+ }
+ }
+
@Test
void activityFanOutWithException() throws TimeoutException {
final String orchestratorName = "ActivityFanOut";
diff --git a/client/src/test/java/com/microsoft/durabletask/ListInstanceIdsQueryTest.java b/client/src/test/java/com/microsoft/durabletask/ListInstanceIdsQueryTest.java
new file mode 100644
index 00000000..76902522
--- /dev/null
+++ b/client/src/test/java/com/microsoft/durabletask/ListInstanceIdsQueryTest.java
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link ListInstanceIdsQuery}.
+ */
+public class ListInstanceIdsQueryTest {
+
+ @Test
+ void getRuntimeStatusList_default_isEmptyNonNull() {
+ ListInstanceIdsQuery query = new ListInstanceIdsQuery();
+ assertNotNull(query.getRuntimeStatusList());
+ assertTrue(query.getRuntimeStatusList().isEmpty());
+ }
+
+ @Test
+ void setRuntimeStatusList_null_normalizesToEmptyList() {
+ ListInstanceIdsQuery query = new ListInstanceIdsQuery().setRuntimeStatusList(null);
+ assertNotNull(query.getRuntimeStatusList());
+ assertTrue(query.getRuntimeStatusList().isEmpty());
+ }
+
+ @Test
+ void setRuntimeStatusList_copiesInput_soExternalMutationDoesNotAffectQuery() {
+ List source = new ArrayList<>();
+ source.add(OrchestrationRuntimeStatus.COMPLETED);
+
+ ListInstanceIdsQuery query = new ListInstanceIdsQuery().setRuntimeStatusList(source);
+ source.add(OrchestrationRuntimeStatus.FAILED);
+
+ assertEquals(Arrays.asList(OrchestrationRuntimeStatus.COMPLETED), query.getRuntimeStatusList());
+ }
+
+ @Test
+ void setPageSize_positiveValue_updatesPageSize() {
+ ListInstanceIdsQuery query = new ListInstanceIdsQuery().setPageSize(25);
+
+ assertEquals(25, query.getPageSize());
+ }
+
+ @Test
+ void setPageSize_zeroOrNegative_throws() {
+ assertThrows(IllegalArgumentException.class, () -> new ListInstanceIdsQuery().setPageSize(0));
+ assertThrows(IllegalArgumentException.class, () -> new ListInstanceIdsQuery().setPageSize(-1));
+ }
+}
diff --git a/client/src/test/java/com/microsoft/durabletask/TaskEntityTest.java b/client/src/test/java/com/microsoft/durabletask/TaskEntityTest.java
index e04f43a5..7747ee76 100644
--- a/client/src/test/java/com/microsoft/durabletask/TaskEntityTest.java
+++ b/client/src/test/java/com/microsoft/durabletask/TaskEntityTest.java
@@ -42,6 +42,23 @@ protected Class getStateType() {
}
}
+ /** A non-public entity implementation, matching internal feature entities. */
+ private static class PrivateEntity extends AbstractTaskEntity {
+ public String get() {
+ return this.state;
+ }
+
+ @Override
+ protected String initializeState(TaskEntityOperation operation) {
+ return "private-state";
+ }
+
+ @Override
+ protected Class getStateType() {
+ return String.class;
+ }
+ }
+
/**
* Entity that accepts a TaskEntityContext as a method parameter.
*/
@@ -247,6 +264,12 @@ void reflectionDispatch_methodWithReturnValue() throws Exception {
assertEquals(42, result);
}
+ @Test
+ void reflectionDispatch_publicOperationOnPrivateEntityClass() throws Exception {
+ Object result = new PrivateEntity().run(createOperation("get"));
+ assertEquals("private-state", result);
+ }
+
@Test
void reflectionDispatch_caseInsensitive() throws Exception {
CounterEntity entity = new CounterEntity();
diff --git a/exporthistory/README.md b/exporthistory/README.md
new file mode 100644
index 00000000..ee0ef494
--- /dev/null
+++ b/exporthistory/README.md
@@ -0,0 +1,135 @@
+# Durable Task Export History (Java)
+
+Durable, resumable export of **terminal orchestration history** to Azure Blob Storage for the Durable Task Java
+SDK — for compliance, audit, and offline analysis before instances age out of the task hub.
+
+This module is at parity with the .NET `Microsoft.DurableTask.ExportHistory` (preview) feature: a checkpointed
+entity + orchestrator that pages terminal instances by completion window, fans out per-instance export activities,
+and uploads serialized history (gzipped JSONL by default) to a customer-owned blob container.
+
+> **Status:** preview (`0.1.0`).
+
+## Install
+
+Add the module dependency alongside the core `client` (and your Durable Task Scheduler extension):
+
+```groovy
+implementation 'com.microsoft:durabletask-exporthistory:0.1.0'
+```
+
+The export activities upload to Azure Blob Storage via `azure-storage-blob`. If you authenticate with a managed
+identity, also add `com.azure:azure-identity` to your application.
+
+## Usage
+
+```java
+// Storage destination for exported history (Azure Blob)
+ExportHistoryStorageOptions storage = new ExportHistoryStorageOptions()
+ .setConnectionString(System.getenv("EXPORT_HISTORY_STORAGE_CONNECTION_STRING"))
+ .setContainerName("orchestration-history")
+ .setPrefix("exports/"); // optional
+ // identity alt: .setAccountUri(uri).setCredential(new DefaultAzureCredentialBuilder().build())
+
+// Build a client first — the export activities need a client to the same backend.
+DurableTaskGrpcClientBuilder clientBuilder = new DurableTaskGrpcClientBuilder();
+DurableTaskSchedulerClientExtensions.useDurableTaskScheduler(clientBuilder, dtsConn);
+DurableTaskClient client = clientBuilder.build();
+
+// Worker: register the export entity + orchestrators + activities (uploads run here).
+DurableTaskGrpcWorkerBuilder workerBuilder = new DurableTaskGrpcWorkerBuilder();
+DurableTaskSchedulerWorkerExtensions.useDurableTaskScheduler(workerBuilder, dtsConn);
+ExportHistoryWorkerExtensions.useExportHistory(workerBuilder, storage, client);
+
+// Client: obtain an ExportHistoryClient bound to the destination.
+ExportHistoryClient export = ExportHistoryClientExtensions.useExportHistory(client, storage);
+
+// Create a job: archive everything completed in a window.
+ExportHistoryJobClient job = export.createJob(new ExportJobCreationOptions("nightly-archive")
+ .setMode(ExportMode.BATCH)
+ .setCompletedTimeFrom(Instant.parse("2026-06-01T00:00:00Z"))
+ .setCompletedTimeTo(Instant.parse("2026-06-25T00:00:00Z"))
+ .setRuntimeStatus(List.of(OrchestrationRuntimeStatus.COMPLETED))
+ .setMaxInstancesPerBatch(200)); // 1–1000, default 100
+
+// Inspect progress.
+ExportJobDescription d = job.describe();
+System.out.println(d.getStatus() + " exported=" + d.getExportedInstances());
+```
+
+### Modes
+
+- **BATCH** — exports a fixed completion-time window and completes. Requires `completedTimeFrom` and
+ `completedTimeTo` (the upper bound must not be in the future).
+- **CONTINUOUS** — tails newly-completed terminal instances on a 1-minute idle loop until the job is deleted.
+
+### Terminal statuses only
+
+Export supports terminal orchestration statuses only: `COMPLETED`, `FAILED`, `TERMINATED`. When no status filter is
+supplied, all three are exported.
+
+## Required settings
+
+- **DTS connection** — the one your app already uses; no new value.
+- **Blob destination** — a container name plus either a storage **connection string** or **identity**
+ (`AccountUri` + `TokenCredential`). Prefix and format (JSONL + gzip) are optional with defaults. The storage
+ secret is held worker-side, not persisted in task-hub state.
+- **Permissions** — the storage credential needs blob write on the container; the DTS credential needs
+ orchestration read.
+
+## Export format
+
+Each blob holds the instance's full history, and the **blob body is byte-for-byte identical to the .NET
+`Microsoft.DurableTask.ExportHistory` output** (pinned by a test against golden output captured from
+`Microsoft.Azure.DurableTask.Core`):
+
+- **JSONL** (default, gzipped) is one JSON object per line; **JSON** is a single array.
+- Each event is `{"eventType": "...", , "eventId": N, "isPlayed": false, "timestamp": "..."}`.
+- camelCase field names, null fields omitted, empty maps as `{}`, enum values in PascalCase (e.g. `"Completed"`),
+ timestamps as trimmed ISO-8601 ending in `Z`, and the same HTML-safe string escaping (`"` → `\u0022`,
+ `& < > ' +` and all non-ASCII → `\uXXXX`).
+
+Blob **names**: a lowercase-hex SHA-256 of `"|"` plus the format extension.
+
+## Differences from .NET
+
+1. **Worker registration takes an explicit client** — `useExportHistory(workerBuilder, storage, client)`. Java has
+ no dependency injection, so the export activities require a `DurableTaskClient` for the same backend.
+2. **Entity events** — the .NET export folds durable-entity operations into core events via a stateful converter, so
+ it has no distinct JSON for them; Java emits entity events in a Java-native shape instead (a reflective projection
+ with an `eventType` discriminator, e.g. `"EntityLockGranted"` — matching the Python SDK, which also keeps entity
+ events as first-class types). Every **non-entity** event is byte-for-byte identical to .NET, so only orchestrations
+ that call entities differ, and only on those entity-specific lines.
+
+## Backend requirement
+
+The export feature relies on the `ListInstanceIds` and `StreamInstanceHistory` gRPC operations. Managed DTS serves
+both; the emulator / self-hosted sidecar needs **≥ v0.4.22**. Against an older backend, a raw gRPC `UNIMPLEMENTED`
+surfaces (matching .NET).
+
+## Validating the export
+
+Locally, with the DTS emulator and Azurite:
+
+1. Start the backends:
+ ```
+ docker run --name durabletask-emulator -p 4001:8080 -d mcr.microsoft.com/dts/dts-emulator:latest
+ docker run --name azurite -p 10000:10000 -d mcr.microsoft.com/azure-storage/azurite azurite-blob --blobHost 0.0.0.0
+ ```
+2. Point the app at them: DTS connection `Endpoint=http://localhost:4001;Authentication=None`, storage = the Azurite
+ dev connection string, container `orchestration-history`.
+3. Run an orchestration to a terminal state, then create a `BATCH` export job whose window covers its completion time.
+4. Confirm the job reaches `COMPLETED` and inspect progress:
+ ```java
+ ExportJobDescription d = job.describe();
+ // d.getStatus() == ExportJobStatus.COMPLETED, d.getExportedInstances() >= 1
+ ```
+5. Download the blob from the container (gunzip for JSONL) and inspect it. Every line carries an `eventType`
+ discriminator, `isPlayed:false`, and a trailing `timestamp`.
+
+## Sample
+
+See [`HistoryExportSample`](../samples/src/main/java/io/durabletask/samples/HistoryExportSample.java):
+
+```
+./gradlew :samples:runHistoryExportSample
+```
diff --git a/exporthistory/build.gradle b/exporthistory/build.gradle
new file mode 100644
index 00000000..e24fa0c6
--- /dev/null
+++ b/exporthistory/build.gradle
@@ -0,0 +1,173 @@
+plugins {
+ id 'java-library'
+ id 'maven-publish'
+ id 'signing'
+ id 'com.github.spotbugs' version '6.4.8'
+}
+
+group 'com.microsoft'
+version = '0.1.0'
+archivesBaseName = 'durabletask-exporthistory'
+
+def grpcVersion = '1.78.0'
+def azureCoreVersion = '1.57.1'
+def azureStorageBlobVersion = '12.29.1'
+
+// Java 11 is used to compile and run all tests. Set the JDK_11 env var to your
+// local JDK 11 home directory, e.g. C:/Program Files/Java/openjdk-11.0.12_7/
+// If unset, falls back to the current JDK running Gradle.
+def rawJdkPath = System.env.JDK_11 ?: System.getProperty("java.home")
+def PATH_TO_TEST_JAVA_RUNTIME = rawJdkPath
+if (rawJdkPath != null) {
+ def f = new File(rawJdkPath)
+ if (f.isFile()) {
+ PATH_TO_TEST_JAVA_RUNTIME = f.parentFile.parentFile.absolutePath
+ }
+}
+def isWindows = System.getProperty("os.name").toLowerCase().contains("win")
+def exeSuffix = isWindows ? ".exe" : ""
+
+dependencies {
+ api project(':client')
+
+ // Azure Storage Blobs — export destination for serialized history.
+ implementation "com.azure:azure-storage-blob:${azureStorageBlobVersion}"
+
+ // TokenCredential abstraction (from azure-core) — 'api' because
+ // ExportHistoryStorageOptions exposes TokenCredential in its public API.
+ api "com.azure:azure-core:${azureCoreVersion}"
+
+ // gRPC API (used by the export activities that call the client wrappers).
+ implementation "io.grpc:grpc-api:${grpcVersion}"
+ implementation "io.grpc:grpc-protobuf:${grpcVersion}"
+ implementation "io.grpc:grpc-stub:${grpcVersion}"
+
+ // NOTE: azure-identity is NOT included here. Users who need
+ // DefaultAzureCredential should add it to their own project.
+
+ testImplementation 'org.mockito:mockito-core:5.21.0'
+ testImplementation 'org.mockito:mockito-junit-jupiter:5.21.0'
+ testImplementation project(':azuremanaged')
+}
+
+compileJava {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+}
+compileTestJava {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ options.fork = true
+ options.forkOptions.executable = "${PATH_TO_TEST_JAVA_RUNTIME}/bin/javac${exeSuffix}"
+}
+
+tasks.withType(Test) {
+ executable = new File("${PATH_TO_TEST_JAVA_RUNTIME}", "bin/java${exeSuffix}")
+}
+
+test {
+ useJUnitPlatform {
+ // Skip tests tagged as "integration" since those require
+ // external dependencies (DTS emulator + Azurite).
+ excludeTags "integration"
+ }
+}
+
+// Integration tests require DTS emulator (default localhost:4001) and Azurite on localhost:10000.
+task integrationTest(type: Test) {
+ useJUnitPlatform {
+ includeTags 'integration'
+ }
+ dependsOn build
+ shouldRunAfter test
+ testLogging.showStandardStreams = true
+ ignoreFailures = false
+}
+
+spotbugs {
+ toolVersion = '4.9.8'
+ effort = com.github.spotbugs.snom.Effort.valueOf('MAX')
+ reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH')
+ ignoreFailures = true
+}
+
+spotbugsMain {
+ reports {
+ html {
+ required = true
+ stylesheet = 'fancy-hist.xsl'
+ }
+ xml {
+ required = true
+ }
+ }
+}
+
+spotbugsTest {
+ reports {
+ html {
+ required = true
+ stylesheet = 'fancy-hist.xsl'
+ }
+ xml {
+ required = true
+ }
+ }
+}
+
+publishing {
+ repositories {
+ maven {
+ url "file://$project.rootDir/repo"
+ }
+ }
+ publications {
+ mavenJava(MavenPublication) {
+ from components.java
+ artifactId = archivesBaseName
+ pom {
+ name = 'Durable Task Export History for Java'
+ description = 'This package provides durable export of terminal orchestration history to Azure Blob Storage for the Durable Task Java SDK.'
+ url = "https://github.com/microsoft/durabletask-java/tree/main/exporthistory"
+ licenses {
+ license {
+ name = "MIT License"
+ url = "https://opensource.org/licenses/MIT"
+ distribution = "repo"
+ }
+ }
+ developers {
+ developer {
+ id = "Microsoft"
+ name = "Microsoft Corporation"
+ }
+ }
+ scm {
+ connection = "scm:git:https://github.com/microsoft/durabletask-java"
+ developerConnection = "scm:git:git@github.com:microsoft/durabletask-java"
+ url = "https://github.com/microsoft/durabletask-java/tree/main/exporthistory"
+ }
+ withXml {
+ project.configurations.compileOnly.allDependencies.each { dependency ->
+ asNode().dependencies[0].appendNode("dependency").with {
+ it.appendNode("groupId", dependency.group)
+ it.appendNode("artifactId", dependency.name)
+ it.appendNode("version", dependency.version)
+ it.appendNode("scope", "provided")
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+signing {
+ required = !project.hasProperty("skipSigning")
+ sign publishing.publications.mavenJava
+}
+
+java {
+ withSourcesJar()
+ withJavadocJar()
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java
new file mode 100644
index 00000000..eec1808d
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java
@@ -0,0 +1,122 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.azure.core.util.BinaryData;
+import com.azure.storage.blob.BlobClient;
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobServiceClient;
+import com.azure.storage.blob.BlobServiceClientBuilder;
+import com.azure.storage.blob.models.BlobHttpHeaders;
+import com.azure.storage.common.policy.RequestRetryOptions;
+import com.azure.storage.common.policy.RetryPolicyType;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.zip.GZIPOutputStream;
+
+/**
+ * Writes serialized orchestration history to Azure Blob Storage.
+ *
+ * Built once from {@link ExportHistoryStorageOptions} (connection-string or identity auth) and reused across export
+ * activities. The target container is taken from each {@link ExportDestination}; the container is created on first
+ * use.
+ */
+final class BlobExportWriter {
+
+ private final BlobServiceClient serviceClient;
+
+ /**
+ * Creates a {@code BlobExportWriter} from storage options.
+ *
+ * @param options the storage options (connection string, or account URI + credential)
+ * @throws IllegalArgumentException if neither connection string nor account URI/credential are provided
+ */
+ BlobExportWriter(ExportHistoryStorageOptions options) {
+ if (options == null) {
+ throw new IllegalArgumentException("options must not be null.");
+ }
+
+ boolean hasConnectionString = options.getConnectionString() != null
+ && !options.getConnectionString().isEmpty();
+ boolean hasIdentityAuth = options.getAccountUri() != null && options.getCredential() != null;
+
+ if (!hasConnectionString && !hasIdentityAuth) {
+ throw new IllegalArgumentException(
+ "Either ConnectionString or AccountUri and Credential must be provided.");
+ }
+
+ // Exponential retry, matching the azure-blob-payloads BlobPayloadStore configuration.
+ RequestRetryOptions retryOptions = new RequestRetryOptions(
+ RetryPolicyType.EXPONENTIAL,
+ 8,
+ 120,
+ 250L,
+ 10_000L,
+ null);
+
+ if (hasIdentityAuth) {
+ this.serviceClient = new BlobServiceClientBuilder()
+ .endpoint(options.getAccountUri().toString())
+ .credential(options.getCredential())
+ .retryOptions(retryOptions)
+ .buildClient();
+ } else {
+ this.serviceClient = new BlobServiceClientBuilder()
+ .connectionString(options.getConnectionString())
+ .retryOptions(retryOptions)
+ .buildClient();
+ }
+ }
+
+ /** Package-private constructor for testing with an injected service client. */
+ BlobExportWriter(BlobServiceClient serviceClient) {
+ this.serviceClient = serviceClient;
+ }
+
+ /**
+ * Uploads serialized history content to a blob, creating the container if needed and overwriting any existing
+ * blob with the same name.
+ *
+ * @param containerName the target container
+ * @param blobPath the blob path (including any prefix)
+ * @param content the serialized content
+ * @param format the export format (determines gzip + content type)
+ * @param instanceId the instance ID, recorded as blob metadata
+ */
+ void upload(String containerName, String blobPath, String content, ExportFormat format, String instanceId) {
+ if (containerName == null || containerName.isEmpty()) {
+ throw new IllegalArgumentException("Blob container name must not be null or empty.");
+ }
+ if (blobPath == null || blobPath.isEmpty()) {
+ throw new IllegalArgumentException("Blob path must not be null or empty.");
+ }
+ BlobContainerClient containerClient = this.serviceClient.getBlobContainerClient(containerName);
+ containerClient.createIfNotExists();
+
+ BlobClient blobClient = containerClient.getBlobClient(blobPath);
+
+ byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8);
+ boolean gzip = HistoryEventSerializer.isCompressed(format);
+ byte[] payload = gzip ? gzip(contentBytes) : contentBytes;
+
+ blobClient.upload(BinaryData.fromBytes(payload), true);
+ blobClient.setHttpHeaders(new BlobHttpHeaders()
+ .setContentType(HistoryEventSerializer.contentType(format))
+ .setContentEncoding(gzip ? "gzip" : null));
+ blobClient.setMetadata(Collections.singletonMap("instanceId", instanceId));
+ }
+
+ private static byte[] gzip(byte[] data) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (GZIPOutputStream gzipStream = new GZIPOutputStream(out)) {
+ gzipStream.write(data);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to gzip export content.", e);
+ }
+ return out.toByteArray();
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java
new file mode 100644
index 00000000..b7f6c60d
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import javax.annotation.Nullable;
+import java.util.List;
+
+/**
+ * Request to commit a checkpoint with progress updates and optional failures.
+ *
+ * When {@link #getCheckpoint()} is non-null the cursor moves forward (successful batch); when {@code null} the
+ * cursor is retained (failed batch eligible for retry).
+ */
+final class CommitCheckpointRequest {
+
+ private long scannedInstances;
+ private long exportedInstances;
+ private ExportCheckpoint checkpoint;
+ private List failures;
+
+ /** Creates an empty {@code CommitCheckpointRequest}. */
+ public CommitCheckpointRequest() {
+ }
+
+ /** @return the number of instances scanned in this batch. */
+ public long getScannedInstances() {
+ return this.scannedInstances;
+ }
+
+ /**
+ * Sets the number of instances scanned in this batch.
+ *
+ * @param scannedInstances the scanned count
+ */
+ public void setScannedInstances(long scannedInstances) {
+ this.scannedInstances = scannedInstances;
+ }
+
+ /** @return the number of instances successfully exported in this batch. */
+ public long getExportedInstances() {
+ return this.exportedInstances;
+ }
+
+ /**
+ * Sets the number of instances successfully exported in this batch.
+ *
+ * @param exportedInstances the exported count
+ */
+ public void setExportedInstances(long exportedInstances) {
+ this.exportedInstances = exportedInstances;
+ }
+
+ /** @return the checkpoint to commit, or {@code null} to keep the current checkpoint. */
+ @Nullable
+ public ExportCheckpoint getCheckpoint() {
+ return this.checkpoint;
+ }
+
+ /**
+ * Sets the checkpoint to commit. If {@code null}, the cursor does not move forward (retry of the same batch).
+ *
+ * @param checkpoint the checkpoint, or {@code null}
+ */
+ public void setCheckpoint(@Nullable ExportCheckpoint checkpoint) {
+ this.checkpoint = checkpoint;
+ }
+
+ /** @return the list of failed instance exports, or {@code null}. */
+ @Nullable
+ public List getFailures() {
+ return this.failures;
+ }
+
+ /**
+ * Sets the list of failed instance exports.
+ *
+ * @param failures the failures, or {@code null}
+ */
+ public void setFailures(@Nullable List failures) {
+ this.failures = failures;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java
new file mode 100644
index 00000000..8019594a
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java
@@ -0,0 +1,101 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.EntityInstanceId;
+import com.microsoft.durabletask.TaskOrchestration;
+import com.microsoft.durabletask.TaskOrchestrationContext;
+
+import javax.annotation.Nullable;
+
+/**
+ * Orchestrator that executes a single operation on an export job entity and returns its result.
+ *
+ * The client schedules this orchestrator (rather than signaling the entity directly) so it can await completion and
+ * surface validation errors.
+ */
+final class ExecuteExportJobOperationOrchestrator implements TaskOrchestration {
+
+ /** The registered orchestration name. */
+ public static final String NAME = "ExecuteExportJobOperationOrchestrator";
+
+ @Override
+ public void run(TaskOrchestrationContext ctx) {
+ ExportJobOperationRequest input = ctx.getInput(ExportJobOperationRequest.class);
+ Object result = ctx.getEntities()
+ .callEntity(input.getEntityId(), input.getOperationName(), input.getInput(), Object.class)
+ .await();
+ ctx.complete(result);
+ }
+}
+
+/**
+ * Request to execute a single operation on an export job entity, scheduled by the client through
+ * {@link ExecuteExportJobOperationOrchestrator} so the caller can await completion and surface validation errors.
+ */
+final class ExportJobOperationRequest {
+
+ private EntityInstanceId entityId;
+ private String operationName;
+ private Object input;
+
+ /** Creates an empty {@code ExportJobOperationRequest} (for deserialization). */
+ public ExportJobOperationRequest() {
+ }
+
+ /**
+ * Creates an {@code ExportJobOperationRequest}.
+ *
+ * @param entityId the target entity ID
+ * @param operationName the operation name
+ * @param input the operation input, or {@code null}
+ */
+ public ExportJobOperationRequest(EntityInstanceId entityId, String operationName, @Nullable Object input) {
+ this.entityId = entityId;
+ this.operationName = operationName;
+ this.input = input;
+ }
+
+ /** @return the target entity ID. */
+ public EntityInstanceId getEntityId() {
+ return this.entityId;
+ }
+
+ /**
+ * Sets the target entity ID.
+ *
+ * @param entityId the entity ID
+ */
+ public void setEntityId(EntityInstanceId entityId) {
+ this.entityId = entityId;
+ }
+
+ /** @return the operation name. */
+ public String getOperationName() {
+ return this.operationName;
+ }
+
+ /**
+ * Sets the operation name.
+ *
+ * @param operationName the operation name
+ */
+ public void setOperationName(String operationName) {
+ this.operationName = operationName;
+ }
+
+ /** @return the operation input, or {@code null}. */
+ @Nullable
+ public Object getInput() {
+ return this.input;
+ }
+
+ /**
+ * Sets the operation input.
+ *
+ * @param input the input, or {@code null}
+ */
+ public void setInput(@Nullable Object input) {
+ this.input = input;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java
new file mode 100644
index 00000000..c0c1efcd
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java
@@ -0,0 +1,95 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+
+/**
+ * Computes export blob names and paths. The blob name is a lowercase-hex SHA-256 hash of
+ * {@code "|"} plus the format-specific extension. The timestamp is rendered with a
+ * fixed round-trip format ({@code yyyy-MM-ddTHH:mm:ss.fffffff+00:00}) so blob names are stable and idempotent under
+ * retry.
+ */
+final class ExportBlobNaming {
+
+ // Date-time portion of the timestamp format; the fractional seconds and offset are appended manually.
+ private static final DateTimeFormatter TIMESTAMP_DATE_TIME =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
+
+ private ExportBlobNaming() {
+ }
+
+ /**
+ * Builds the blob file name (without any prefix) for an instance export.
+ *
+ * @param completedTimestamp the instance completion time
+ * @param instanceId the instance ID
+ * @param format the export format
+ * @return the blob file name, e.g. {@code ".jsonl.gz"}
+ */
+ static String blobFileName(Instant completedTimestamp, String instanceId, ExportFormat format) {
+ String hashInput = formatTimestamp(completedTimestamp) + "|" + instanceId;
+ return sha256Hex(hashInput) + "." + HistoryEventSerializer.fileExtension(format);
+ }
+
+ /**
+ * Formats an instant as {@code yyyy-MM-ddTHH:mm:ss.fffffff+00:00} (seven fractional digits, explicit UTC
+ * offset). The instant is treated as UTC.
+ *
+ * Note: instance timestamps are truncated to milliseconds upstream, so the sub-millisecond fractional digits
+ * are always zero here.
+ *
+ * @param instant the timestamp (treated as UTC)
+ * @return the formatted timestamp string
+ */
+ static String formatTimestamp(Instant instant) {
+ OffsetDateTime utc = instant.atOffset(ZoneOffset.UTC);
+ long ticks = utc.getNano() / 100L;
+ return TIMESTAMP_DATE_TIME.format(utc) + "." + String.format(Locale.ROOT, "%07d", ticks) + "+00:00";
+ }
+
+ /**
+ * Combines an optional prefix with a blob file name.
+ *
+ * @param prefix the blob path prefix, or {@code null}/empty for none
+ * @param fileName the blob file name
+ * @return the full blob path
+ */
+ static String blobPath(String prefix, String fileName) {
+ if (prefix == null || prefix.isEmpty()) {
+ return fileName;
+ }
+ return trimTrailingSlashes(prefix) + "/" + fileName;
+ }
+
+ private static String sha256Hex(String value) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hashBytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(hashBytes.length * 2);
+ for (byte b : hashBytes) {
+ sb.append(Character.forDigit((b >> 4) & 0xF, 16));
+ sb.append(Character.forDigit(b & 0xF, 16));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is guaranteed to be available on every JVM.
+ throw new IllegalStateException("SHA-256 algorithm not available.", e);
+ }
+ }
+
+ private static String trimTrailingSlashes(String value) {
+ int end = value.length();
+ while (end > 0 && value.charAt(end - 1) == '/') {
+ end--;
+ }
+ return value.substring(0, end);
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportCheckpoint.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportCheckpoint.java
new file mode 100644
index 00000000..ed1af603
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportCheckpoint.java
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import javax.annotation.Nullable;
+
+/**
+ * Checkpoint information used to resume an export.
+ *
+ * The {@code lastInstanceKey} is the pagination cursor returned by the client {@code listInstanceIds} wrapper.
+ */
+public final class ExportCheckpoint {
+
+ private String lastInstanceKey;
+
+ /** Creates an empty {@code ExportCheckpoint} (for deserialization). */
+ public ExportCheckpoint() {
+ }
+
+ /**
+ * Creates an {@code ExportCheckpoint}.
+ *
+ * @param lastInstanceKey the pagination cursor, or {@code null}
+ */
+ public ExportCheckpoint(@Nullable String lastInstanceKey) {
+ this.lastInstanceKey = lastInstanceKey;
+ }
+
+ /** @return the pagination cursor, or {@code null}. */
+ @Nullable
+ public String getLastInstanceKey() {
+ return this.lastInstanceKey;
+ }
+
+ /**
+ * Sets the pagination cursor.
+ *
+ * @param lastInstanceKey the cursor, or {@code null}
+ */
+ public void setLastInstanceKey(@Nullable String lastInstanceKey) {
+ this.lastInstanceKey = lastInstanceKey;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportDestination.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportDestination.java
new file mode 100644
index 00000000..0922bf27
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportDestination.java
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import javax.annotation.Nullable;
+
+/**
+ * Export destination settings for Azure Blob Storage.
+ */
+public final class ExportDestination {
+
+ private String container;
+ private String prefix;
+
+ /** Creates an empty {@code ExportDestination} (for deserialization). */
+ public ExportDestination() {
+ }
+
+ /**
+ * Creates an {@code ExportDestination} for the given container.
+ *
+ * @param container the blob container name
+ */
+ public ExportDestination(String container) {
+ this.container = container;
+ }
+
+ /** @return the blob container name. */
+ public String getContainer() {
+ return this.container;
+ }
+
+ /**
+ * Sets the blob container name.
+ *
+ * @param container the container name
+ */
+ public void setContainer(String container) {
+ this.container = container;
+ }
+
+ /** @return the optional blob path prefix, or {@code null}. */
+ @Nullable
+ public String getPrefix() {
+ return this.prefix;
+ }
+
+ /**
+ * Sets the optional blob path prefix.
+ *
+ * @param prefix the prefix, or {@code null}
+ */
+ public void setPrefix(@Nullable String prefix) {
+ this.prefix = prefix;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java
new file mode 100644
index 00000000..fa53c08a
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java
@@ -0,0 +1,91 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import java.time.Instant;
+
+/**
+ * Failure of a specific instance export.
+ */
+final class ExportFailure {
+
+ private String instanceId;
+ private String reason;
+ private int attemptCount;
+ private Instant lastAttempt;
+
+ /** Creates an empty {@code ExportFailure} (for deserialization). */
+ public ExportFailure() {
+ }
+
+ /**
+ * Creates an {@code ExportFailure}.
+ *
+ * @param instanceId the instance ID that failed to export
+ * @param reason the failure reason
+ * @param attemptCount the number of attempts made
+ * @param lastAttempt the timestamp of the last attempt
+ */
+ public ExportFailure(String instanceId, String reason, int attemptCount, Instant lastAttempt) {
+ this.instanceId = instanceId;
+ this.reason = reason;
+ this.attemptCount = attemptCount;
+ this.lastAttempt = lastAttempt;
+ }
+
+ /** @return the instance ID that failed to export. */
+ public String getInstanceId() {
+ return this.instanceId;
+ }
+
+ /**
+ * Sets the instance ID that failed to export.
+ *
+ * @param instanceId the instance ID
+ */
+ public void setInstanceId(String instanceId) {
+ this.instanceId = instanceId;
+ }
+
+ /** @return the failure reason. */
+ public String getReason() {
+ return this.reason;
+ }
+
+ /**
+ * Sets the failure reason.
+ *
+ * @param reason the reason
+ */
+ public void setReason(String reason) {
+ this.reason = reason;
+ }
+
+ /** @return the number of attempts made. */
+ public int getAttemptCount() {
+ return this.attemptCount;
+ }
+
+ /**
+ * Sets the number of attempts made.
+ *
+ * @param attemptCount the attempt count
+ */
+ public void setAttemptCount(int attemptCount) {
+ this.attemptCount = attemptCount;
+ }
+
+ /** @return the timestamp of the last attempt. */
+ public Instant getLastAttempt() {
+ return this.lastAttempt;
+ }
+
+ /**
+ * Sets the timestamp of the last attempt.
+ *
+ * @param lastAttempt the last attempt time
+ */
+ public void setLastAttempt(Instant lastAttempt) {
+ this.lastAttempt = lastAttempt;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFilter.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFilter.java
new file mode 100644
index 00000000..7df1b373
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFilter.java
@@ -0,0 +1,83 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * Filter criteria for selecting orchestration instances to export.
+ */
+public final class ExportFilter {
+
+ private Instant completedTimeFrom;
+ private Instant completedTimeTo;
+ private List runtimeStatus;
+
+ /** Creates an empty {@code ExportFilter} (for deserialization). */
+ public ExportFilter() {
+ }
+
+ /**
+ * Creates an {@code ExportFilter}.
+ *
+ * @param completedTimeFrom the inclusive completion-time lower bound
+ * @param completedTimeTo the inclusive completion-time upper bound, or {@code null}
+ * @param runtimeStatus the terminal runtime statuses to filter by, or {@code null}
+ */
+ public ExportFilter(
+ Instant completedTimeFrom,
+ @Nullable Instant completedTimeTo,
+ @Nullable List runtimeStatus) {
+ this.completedTimeFrom = completedTimeFrom;
+ this.completedTimeTo = completedTimeTo;
+ this.runtimeStatus = runtimeStatus;
+ }
+
+ /** @return the inclusive completion-time lower bound. */
+ public Instant getCompletedTimeFrom() {
+ return this.completedTimeFrom;
+ }
+
+ /**
+ * Sets the inclusive completion-time lower bound.
+ *
+ * @param completedTimeFrom the lower bound
+ */
+ public void setCompletedTimeFrom(Instant completedTimeFrom) {
+ this.completedTimeFrom = completedTimeFrom;
+ }
+
+ /** @return the inclusive completion-time upper bound, or {@code null}. */
+ @Nullable
+ public Instant getCompletedTimeTo() {
+ return this.completedTimeTo;
+ }
+
+ /**
+ * Sets the inclusive completion-time upper bound.
+ *
+ * @param completedTimeTo the upper bound, or {@code null}
+ */
+ public void setCompletedTimeTo(@Nullable Instant completedTimeTo) {
+ this.completedTimeTo = completedTimeTo;
+ }
+
+ /** @return the terminal runtime statuses to filter by, or {@code null}. */
+ @Nullable
+ public List getRuntimeStatus() {
+ return this.runtimeStatus;
+ }
+
+ /**
+ * Sets the terminal runtime statuses to filter by.
+ *
+ * @param runtimeStatus the runtime statuses, or {@code null}
+ */
+ public void setRuntimeStatus(@Nullable List runtimeStatus) {
+ this.runtimeStatus = runtimeStatus;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormat.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormat.java
new file mode 100644
index 00000000..683ba005
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormat.java
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import java.util.Objects;
+
+/**
+ * Export format settings. The default is {@link ExportFormatKind#JSONL} with schema version {@code "1.0"}.
+ */
+public final class ExportFormat {
+
+ /** The default schema version. */
+ public static final String DEFAULT_SCHEMA_VERSION = "1.0";
+
+ private final ExportFormatKind kind;
+ private final String schemaVersion;
+
+ /**
+ * Creates a new {@code ExportFormat} with the default kind ({@link ExportFormatKind#JSONL}) and
+ * schema version ({@value #DEFAULT_SCHEMA_VERSION}).
+ */
+ public ExportFormat() {
+ this(ExportFormatKind.JSONL, DEFAULT_SCHEMA_VERSION);
+ }
+
+ /**
+ * Creates a new {@code ExportFormat}.
+ *
+ * @param kind the export format kind
+ * @param schemaVersion the schema version
+ */
+ public ExportFormat(ExportFormatKind kind, String schemaVersion) {
+ this.kind = Objects.requireNonNull(kind, "kind must not be null");
+ this.schemaVersion = Objects.requireNonNull(schemaVersion, "schemaVersion must not be null");
+ }
+
+ /**
+ * Gets the default export format (JSONL with schema version {@value #DEFAULT_SCHEMA_VERSION}).
+ *
+ * @return the default export format
+ */
+ public static ExportFormat getDefault() {
+ return new ExportFormat();
+ }
+
+ /** @return the export format kind. */
+ public ExportFormatKind getKind() {
+ return this.kind;
+ }
+
+ /** @return the schema version. */
+ public String getSchemaVersion() {
+ return this.schemaVersion;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ExportFormat)) {
+ return false;
+ }
+ ExportFormat that = (ExportFormat) o;
+ return this.kind == that.kind && this.schemaVersion.equals(that.schemaVersion);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(this.kind, this.schemaVersion);
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormatKind.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormatKind.java
new file mode 100644
index 00000000..1488eae3
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormatKind.java
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+/**
+ * The kind of export format.
+ */
+public enum ExportFormatKind {
+ /** JSONL format (one history event per line, compressed with gzip). */
+ JSONL,
+
+ /** JSON format (array of history events, uncompressed). */
+ JSON
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClient.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClient.java
new file mode 100644
index 00000000..5819a8af
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClient.java
@@ -0,0 +1,114 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.DurableTaskClient;
+import com.microsoft.durabletask.EntityMetadata;
+import com.microsoft.durabletask.EntityQuery;
+import com.microsoft.durabletask.EntityQueryResult;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Convenience client for creating, reading, and listing export jobs, backed by entity operations and reads.
+ *
+ * Obtain an instance via {@link ExportHistoryClientExtensions#useExportHistory}.
+ */
+public final class ExportHistoryClient {
+
+ private static final String ENTITY_ID_PREFIX = "@" + ExportJob.NAME.toLowerCase(java.util.Locale.ROOT) + "@";
+
+ private final DurableTaskClient durableTaskClient;
+ private final ExportHistoryStorageOptions storageOptions;
+
+ ExportHistoryClient(DurableTaskClient durableTaskClient, ExportHistoryStorageOptions storageOptions) {
+ this.durableTaskClient = durableTaskClient;
+ this.storageOptions = storageOptions;
+ }
+
+ /**
+ * Creates a new export job and returns a client bound to it.
+ *
+ * @param options the creation options
+ * @return a job client bound to the created job
+ */
+ public ExportHistoryJobClient createJob(ExportJobCreationOptions options) {
+ if (options == null) {
+ throw new IllegalArgumentException("options must not be null.");
+ }
+ ExportHistoryJobClient jobClient = this.getJobClient(options.getJobId());
+ jobClient.create(options);
+ return jobClient;
+ }
+
+ /**
+ * Gets the description of an export job.
+ *
+ * @param jobId the export job ID
+ * @return the export job description
+ * @throws ExportJobNotFoundException if the job does not exist
+ */
+ public ExportJobDescription getJob(String jobId) {
+ return this.getJobClient(jobId).describe();
+ }
+
+ /**
+ * Gets a job client bound to the specified job ID without creating it.
+ *
+ * @param jobId the export job ID
+ * @return a job client
+ */
+ public ExportHistoryJobClient getJobClient(String jobId) {
+ return new ExportHistoryJobClient(this.durableTaskClient, jobId, this.storageOptions);
+ }
+
+ /**
+ * Lists export jobs matching the query (single page).
+ *
+ * @param filter the query, or {@code null} for defaults
+ * @return a page of matching export job descriptions and a continuation token
+ */
+ public ExportJobQueryResult listJobs(@Nullable ExportJobQuery filter) {
+ ExportJobQuery query = filter == null ? new ExportJobQuery() : filter;
+ String prefix = query.getJobIdPrefix() == null ? "" : query.getJobIdPrefix();
+ int pageSize = query.getPageSize() == null ? ExportJobQuery.DEFAULT_PAGE_SIZE : query.getPageSize();
+
+ EntityQuery entityQuery = new EntityQuery()
+ .setInstanceIdStartsWith(ENTITY_ID_PREFIX + prefix)
+ .setIncludeState(true)
+ .setPageSize(pageSize)
+ .setContinuationToken(query.getContinuationToken());
+
+ EntityQueryResult result = this.durableTaskClient.getEntities().queryEntities(entityQuery);
+
+ List jobs = new ArrayList<>();
+ for (EntityMetadata metadata : result.getEntities()) {
+ ExportJobState state = metadata.readStateAs(ExportJobState.class);
+ if (state == null || !matchesFilter(state, query)) {
+ continue;
+ }
+ jobs.add(ExportJobDescription.fromState(metadata.getEntityInstanceId().getKey(), state));
+ }
+
+ return new ExportJobQueryResult(jobs, result.getContinuationToken());
+ }
+
+ private static boolean matchesFilter(ExportJobState state, ExportJobQuery filter) {
+ if (filter.getStatus() != null && state.getStatus() != filter.getStatus()) {
+ return false;
+ }
+ Instant createdAt = state.getCreatedAt();
+ if (filter.getCreatedFrom() != null
+ && (createdAt == null || !createdAt.isAfter(filter.getCreatedFrom()))) {
+ return false;
+ }
+ if (filter.getCreatedTo() != null
+ && (createdAt == null || !createdAt.isBefore(filter.getCreatedTo()))) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClientExtensions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClientExtensions.java
new file mode 100644
index 00000000..7a4680bf
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClientExtensions.java
@@ -0,0 +1,51 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.DurableTaskClient;
+import com.microsoft.durabletask.DurableTaskGrpcClientBuilder;
+
+import java.util.Objects;
+
+/**
+ * Client-side registration for the export history feature.
+ *
+ * Builds a {@link DurableTaskClient} from the given builder and returns an {@link ExportHistoryClient} bound to the
+ * supplied blob storage destination.
+ */
+public final class ExportHistoryClientExtensions {
+
+ private ExportHistoryClientExtensions() {
+ }
+
+ /**
+ * Enables export history on the given client builder and returns an {@link ExportHistoryClient}.
+ *
+ * @param builder the client builder to build from
+ * @param storage the blob storage destination options
+ * @return an export history client bound to the destination
+ */
+ public static ExportHistoryClient useExportHistory(
+ DurableTaskGrpcClientBuilder builder,
+ ExportHistoryStorageOptions storage) {
+ Objects.requireNonNull(builder, "builder must not be null");
+ Objects.requireNonNull(storage, "storage must not be null");
+ DurableTaskClient client = builder.build();
+ return new ExportHistoryClient(client, storage);
+ }
+
+ /**
+ * Returns an {@link ExportHistoryClient} bound to an existing {@link DurableTaskClient}.
+ *
+ * @param client an existing Durable Task client
+ * @param storage the blob storage destination options
+ * @return an export history client bound to the destination
+ */
+ public static ExportHistoryClient useExportHistory(
+ DurableTaskClient client,
+ ExportHistoryStorageOptions storage) {
+ Objects.requireNonNull(client, "client must not be null");
+ Objects.requireNonNull(storage, "storage must not be null");
+ return new ExportHistoryClient(client, storage);
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java
new file mode 100644
index 00000000..5d705d2b
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+/**
+ * Constants used throughout the export history functionality.
+ */
+final class ExportHistoryConstants {
+
+ /**
+ * The prefix used for generating export job orchestrator instance IDs.
+ * Format: {@code "ExportJob-{jobId}"}.
+ */
+ public static final String ORCHESTRATOR_INSTANCE_ID_PREFIX = "ExportJob-";
+
+ private ExportHistoryConstants() {
+ }
+
+ /**
+ * Generates an orchestrator instance ID for the given export job ID.
+ *
+ * @param jobId the export job ID
+ * @return the orchestrator instance ID
+ */
+ public static String getOrchestratorInstanceId(String jobId) {
+ return ORCHESTRATOR_INSTANCE_ID_PREFIX + jobId;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java
new file mode 100644
index 00000000..fa7bfd11
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java
@@ -0,0 +1,150 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.DurableTaskClient;
+import com.microsoft.durabletask.EntityInstanceId;
+import com.microsoft.durabletask.FailureDetails;
+import com.microsoft.durabletask.OrchestrationMetadata;
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+import com.microsoft.durabletask.TypedEntityMetadata;
+
+import java.time.Duration;
+import java.util.Locale;
+import java.util.concurrent.TimeoutException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Client for managing a single export job via entity operations routed through
+ * {@link ExecuteExportJobOperationOrchestrator}.
+ */
+public final class ExportHistoryJobClient {
+
+ private static final Duration OPERATION_TIMEOUT = Duration.ofSeconds(60);
+ private static final Logger LOGGER = Logger.getLogger(ExportHistoryJobClient.class.getName());
+
+ private final DurableTaskClient durableTaskClient;
+ private final String jobId;
+ private final ExportHistoryStorageOptions storageOptions;
+ private final EntityInstanceId entityId;
+
+ ExportHistoryJobClient(DurableTaskClient durableTaskClient, String jobId, ExportHistoryStorageOptions storageOptions) {
+ if (jobId == null || jobId.isEmpty()) {
+ throw new IllegalArgumentException("jobId must not be null or empty.");
+ }
+ this.durableTaskClient = durableTaskClient;
+ this.jobId = jobId;
+ this.storageOptions = storageOptions;
+ this.entityId = new EntityInstanceId(ExportJob.NAME, jobId);
+ }
+
+ /** @return the export job ID. */
+ public String getJobId() {
+ return this.jobId;
+ }
+
+ /**
+ * Creates the export job, populating the destination from the registered storage options and waiting for the
+ * operation to complete.
+ *
+ * @param options the creation options
+ * @throws ExportJobClientValidationException if creation fails validation or the operation does not complete
+ */
+ public void create(ExportJobCreationOptions options) {
+ if (options == null) {
+ throw new IllegalArgumentException("options must not be null.");
+ }
+
+ String existingPrefix = options.getDestination() == null ? null : options.getDestination().getPrefix();
+ String defaultPrefix = options.getMode().name().toLowerCase(Locale.ROOT) + "-" + this.jobId + "/";
+ String prefix = firstNonNull(existingPrefix, this.storageOptions.getPrefix(), defaultPrefix);
+ String container = options.getDestination() == null || options.getDestination().getContainer() == null
+ ? this.storageOptions.getContainerName()
+ : options.getDestination().getContainer();
+ if (container == null || container.isEmpty()) {
+ throw new ExportJobClientValidationException("Blob container name must not be null or empty.");
+ }
+
+ ExportDestination destination = new ExportDestination(container);
+ destination.setPrefix(prefix);
+ options.setDestination(destination);
+
+ options.validateForCreate();
+
+ ExportJobOperationRequest request = new ExportJobOperationRequest(
+ this.entityId, ExportJobTransitions.OP_CREATE, options);
+
+ OrchestrationMetadata result = scheduleAndWait(request);
+ if (result.getRuntimeStatus() != OrchestrationRuntimeStatus.COMPLETED) {
+ FailureDetails failure = result.getFailureDetails();
+ String detail = failure == null ? "" : failure.getErrorMessage();
+ throw new ExportJobClientValidationException(
+ "Failed to create export job '" + this.jobId + "': " + detail);
+ }
+ }
+
+ /**
+ * Describes the export job by reading its entity state.
+ *
+ * @return the export job description
+ * @throws ExportJobNotFoundException if the job does not exist
+ */
+ public ExportJobDescription describe() {
+ TypedEntityMetadata metadata =
+ this.durableTaskClient.getEntities().getEntityMetadata(this.entityId, ExportJobState.class);
+ ExportJobState state = metadata == null ? null : metadata.getState();
+ if (state == null) {
+ throw new ExportJobNotFoundException(this.jobId);
+ }
+ return ExportJobDescription.fromState(this.jobId, state);
+ }
+
+ /** Deletes the export job entity, then terminates and purges its linked export orchestrator. */
+ public void delete() {
+ ExportJobOperationRequest request = new ExportJobOperationRequest(
+ this.entityId, ExportJobTransitions.OP_DELETE, null);
+ OrchestrationMetadata result = scheduleAndWait(request);
+ if (result.getRuntimeStatus() != OrchestrationRuntimeStatus.COMPLETED) {
+ FailureDetails failure = result.getFailureDetails();
+ String detail = failure == null ? "" : failure.getErrorMessage();
+ throw new ExportJobClientValidationException(
+ "Failed to delete export job '" + this.jobId + "': " + detail);
+ }
+
+ terminateAndPurgeOrchestrator();
+ }
+
+ private void terminateAndPurgeOrchestrator() {
+ String orchestratorInstanceId = ExportHistoryConstants.getOrchestratorInstanceId(this.jobId);
+ try {
+ this.durableTaskClient.terminate(orchestratorInstanceId, "Export job deleted");
+ this.durableTaskClient.waitForInstanceCompletion(
+ orchestratorInstanceId, OPERATION_TIMEOUT, false);
+ this.durableTaskClient.purgeInstance(orchestratorInstanceId);
+ } catch (RuntimeException | TimeoutException ex) {
+ LOGGER.log(Level.WARNING,
+ "Failed to terminate or purge export orchestrator '" + orchestratorInstanceId + "'.", ex);
+ }
+ }
+
+ private OrchestrationMetadata scheduleAndWait(ExportJobOperationRequest request) {
+ String instanceId = this.durableTaskClient.scheduleNewOrchestrationInstance(
+ ExecuteExportJobOperationOrchestrator.NAME, request);
+ try {
+ return this.durableTaskClient.waitForInstanceCompletion(instanceId, OPERATION_TIMEOUT, true);
+ } catch (TimeoutException e) {
+ throw new ExportJobClientValidationException(
+ "Timed out waiting for export job operation on '" + this.jobId + "' to complete.", e);
+ }
+ }
+
+ private static String firstNonNull(String... values) {
+ for (String value : values) {
+ if (value != null) {
+ return value;
+ }
+ }
+ return null;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java
new file mode 100644
index 00000000..9113b058
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java
@@ -0,0 +1,147 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.azure.core.credential.TokenCredential;
+
+import javax.annotation.Nullable;
+import java.net.URI;
+
+/**
+ * Configuration for the Azure Blob Storage destination of an export history job.
+ *
+ * Supports both connection-string and identity-based ({@link TokenCredential}) authentication. Either
+ * {@link #setConnectionString(String)} or both {@link #setAccountUri(URI)} and
+ * {@link #setCredential(TokenCredential)} must be set before use.
+ *
+ *
Example (connection string):
+ *
{@code
+ * ExportHistoryStorageOptions storage = new ExportHistoryStorageOptions()
+ * .setConnectionString(System.getenv("EXPORT_HISTORY_STORAGE_CONNECTION_STRING"))
+ * .setContainerName("orchestration-history")
+ * .setPrefix("exports/");
+ * }
+ *
+ * Example (identity-based):
+ *
{@code
+ * ExportHistoryStorageOptions storage = new ExportHistoryStorageOptions()
+ * .setAccountUri(new URI("https://mystorageaccount.blob.core.windows.net"))
+ * .setCredential(new DefaultAzureCredentialBuilder().build())
+ * .setContainerName("orchestration-history");
+ * }
+ */
+public final class ExportHistoryStorageOptions {
+
+ private String connectionString;
+ private URI accountUri;
+ private TokenCredential credential;
+ private String containerName = "";
+ private String prefix;
+
+ /**
+ * Gets the Azure Storage connection string.
+ *
+ * @return the connection string, or {@code null} if not set
+ */
+ @Nullable
+ public String getConnectionString() {
+ return this.connectionString;
+ }
+
+ /**
+ * Sets the Azure Storage connection string. Either this or {@link #setAccountUri(URI)} +
+ * {@link #setCredential(TokenCredential)} must be set.
+ *
+ * @param connectionString the connection string, or {@code null} to clear
+ * @return this options object
+ */
+ public ExportHistoryStorageOptions setConnectionString(@Nullable String connectionString) {
+ this.connectionString = connectionString;
+ return this;
+ }
+
+ /**
+ * Gets the Azure Storage account URI for identity-based authentication.
+ *
+ * @return the account URI, or {@code null} if not set
+ */
+ @Nullable
+ public URI getAccountUri() {
+ return this.accountUri;
+ }
+
+ /**
+ * Sets the Azure Storage account URI for identity-based authentication. Must be used together with
+ * {@link #setCredential(TokenCredential)}.
+ *
+ * @param accountUri the account URI, or {@code null} to clear
+ * @return this options object
+ */
+ public ExportHistoryStorageOptions setAccountUri(@Nullable URI accountUri) {
+ this.accountUri = accountUri;
+ return this;
+ }
+
+ /**
+ * Gets the credential for identity-based authentication.
+ *
+ * @return the credential, or {@code null} if not set
+ */
+ @Nullable
+ public TokenCredential getCredential() {
+ return this.credential;
+ }
+
+ /**
+ * Sets the credential for identity-based authentication. Must be used together with
+ * {@link #setAccountUri(URI)}.
+ *
+ * @param credential the credential, or {@code null} to clear
+ * @return this options object
+ */
+ public ExportHistoryStorageOptions setCredential(@Nullable TokenCredential credential) {
+ this.credential = credential;
+ return this;
+ }
+
+ /**
+ * Gets the blob container name where exported history is stored.
+ *
+ * @return the container name
+ */
+ public String getContainerName() {
+ return this.containerName;
+ }
+
+ /**
+ * Sets the blob container name where exported history is stored.
+ *
+ * @param containerName the container name
+ * @return this options object
+ */
+ public ExportHistoryStorageOptions setContainerName(String containerName) {
+ this.containerName = containerName;
+ return this;
+ }
+
+ /**
+ * Gets the optional blob path prefix.
+ *
+ * @return the prefix, or {@code null} if not set
+ */
+ @Nullable
+ public String getPrefix() {
+ return this.prefix;
+ }
+
+ /**
+ * Sets an optional prefix for exported blob paths.
+ *
+ * @param prefix the prefix, or {@code null} to clear
+ * @return this options object
+ */
+ public ExportHistoryStorageOptions setPrefix(@Nullable String prefix) {
+ this.prefix = prefix;
+ return this;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java
new file mode 100644
index 00000000..02055c7c
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java
@@ -0,0 +1,93 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.DurableTaskClient;
+import com.microsoft.durabletask.DurableTaskGrpcWorkerBuilder;
+import com.microsoft.durabletask.TaskActivity;
+import com.microsoft.durabletask.TaskActivityFactory;
+import com.microsoft.durabletask.TaskOrchestration;
+import com.microsoft.durabletask.TaskOrchestrationFactory;
+
+import java.util.Objects;
+
+/**
+ * Worker-side registration for the export history feature.
+ *
+ * Registers the {@link ExportJob} entity, the {@link ExportJobOrchestrator} and
+ * {@link ExecuteExportJobOperationOrchestrator} orchestrators, and the {@link ListTerminalInstancesActivity} and
+ * {@link ExportInstanceHistoryActivity} activities.
+ *
+ * The export activities require an explicit {@link DurableTaskClient} to call {@code listInstanceIds} /
+ * {@code getOrchestrationHistory} against the same backend the worker targets.
+ */
+public final class ExportHistoryWorkerExtensions {
+
+ private ExportHistoryWorkerExtensions() {
+ }
+
+ /**
+ * Enables export history on the given worker builder.
+ *
+ * @param builder the worker builder to configure
+ * @param storage the blob storage destination options
+ * @param durableTaskClient a client connected to the same backend, used by the export activities
+ * @return the worker builder, for chaining
+ */
+ public static DurableTaskGrpcWorkerBuilder useExportHistory(
+ DurableTaskGrpcWorkerBuilder builder,
+ ExportHistoryStorageOptions storage,
+ DurableTaskClient durableTaskClient) {
+ Objects.requireNonNull(builder, "builder must not be null");
+ Objects.requireNonNull(storage, "storage must not be null");
+ Objects.requireNonNull(durableTaskClient, "durableTaskClient must not be null");
+
+ BlobExportWriter writer = new BlobExportWriter(storage);
+
+ builder.addEntity(ExportJob.NAME, ExportJob::new);
+
+ builder.addOrchestration(orchestrationFactory(
+ ExportJobOrchestrator.NAME, ExportJobOrchestrator::new));
+ builder.addOrchestration(orchestrationFactory(
+ ExecuteExportJobOperationOrchestrator.NAME, ExecuteExportJobOperationOrchestrator::new));
+
+ builder.addActivity(activityFactory(
+ ListTerminalInstancesActivity.NAME,
+ () -> new ListTerminalInstancesActivity(durableTaskClient)));
+ builder.addActivity(activityFactory(
+ ExportInstanceHistoryActivity.NAME,
+ () -> new ExportInstanceHistoryActivity(durableTaskClient, writer)));
+
+ return builder;
+ }
+
+ private static TaskOrchestrationFactory orchestrationFactory(
+ String name, java.util.function.Supplier supplier) {
+ return new TaskOrchestrationFactory() {
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public TaskOrchestration create() {
+ return supplier.get();
+ }
+ };
+ }
+
+ private static TaskActivityFactory activityFactory(
+ String name, java.util.function.Supplier supplier) {
+ return new TaskActivityFactory() {
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public TaskActivity create() {
+ return supplier.get();
+ }
+ };
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java
new file mode 100644
index 00000000..5a1911a9
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java
@@ -0,0 +1,254 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.DurableTaskClient;
+import com.microsoft.durabletask.OrchestrationMetadata;
+import com.microsoft.durabletask.TaskActivity;
+import com.microsoft.durabletask.TaskActivityContext;
+import com.microsoft.durabletask.history.HistoryEvent;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Activity that exports a single orchestration instance's history to the configured blob destination.
+ *
+ * It reads the instance metadata to confirm a terminal state and obtain the completion timestamp, streams the full
+ * history via {@code getOrchestrationHistory}, serializes it (gzipped JSONL by default), and uploads it to a blob
+ * named from a hash of the completion timestamp and instance ID.
+ */
+final class ExportInstanceHistoryActivity implements TaskActivity {
+
+ /** The registered activity name. */
+ public static final String NAME = "ExportInstanceHistoryActivity";
+
+ private static final Logger LOGGER = Logger.getLogger(ExportInstanceHistoryActivity.class.getName());
+
+ private final DurableTaskClient client;
+ private final BlobExportWriter writer;
+
+ /**
+ * Creates an {@code ExportInstanceHistoryActivity}.
+ *
+ * @param client the Durable Task client used to read metadata and history
+ * @param writer the blob writer used to upload serialized history
+ */
+ public ExportInstanceHistoryActivity(DurableTaskClient client, BlobExportWriter writer) {
+ this.client = client;
+ this.writer = writer;
+ }
+
+ @Override
+ public Object run(TaskActivityContext ctx) {
+ ExportRequest input = ctx.getInput(ExportRequest.class);
+ String instanceId = input.getInstanceId();
+
+ try {
+ OrchestrationMetadata metadata = this.client.getInstanceMetadata(instanceId, false);
+ if (metadata == null || !metadata.isInstanceFound()) {
+ return ExportResult.failure(instanceId, "Instance " + instanceId + " not found");
+ }
+ if (!metadata.isCompleted()) {
+ return ExportResult.failure(instanceId, "Instance " + instanceId + " is not in a completed state");
+ }
+
+ Instant completedTimestamp = metadata.getLastUpdatedAt();
+ List historyEvents = this.client.getOrchestrationHistory(instanceId);
+
+ String blobFileName = ExportBlobNaming.blobFileName(completedTimestamp, instanceId, input.getFormat());
+ String blobPath = ExportBlobNaming.blobPath(input.getDestination().getPrefix(), blobFileName);
+
+ String content = HistoryEventSerializer.serialize(historyEvents, input.getFormat());
+ this.writer.upload(
+ input.getDestination().getContainer(),
+ blobPath,
+ content,
+ input.getFormat(),
+ instanceId);
+
+ LOGGER.log(Level.FINE, "Exported instance {0} ({1} events) to blob {2}",
+ new Object[] {instanceId, historyEvents.size(), blobPath});
+ return ExportResult.success(instanceId, blobPath);
+ } catch (Exception ex) {
+ LOGGER.log(Level.WARNING, "Failed to export instance " + instanceId, ex);
+ return ExportResult.failure(instanceId, ex.getMessage());
+ }
+ }
+}
+
+/**
+ * Input to {@link ExportInstanceHistoryActivity}: the instance to export plus the destination and format.
+ */
+final class ExportRequest {
+
+ private String instanceId;
+ private ExportDestination destination;
+ private ExportFormat format;
+
+ /** Creates an empty {@code ExportRequest} (for deserialization). */
+ public ExportRequest() {
+ }
+
+ /**
+ * Creates an {@code ExportRequest}.
+ *
+ * @param instanceId the instance ID to export
+ * @param destination the export destination
+ * @param format the export format
+ */
+ public ExportRequest(String instanceId, ExportDestination destination, ExportFormat format) {
+ this.instanceId = instanceId;
+ this.destination = destination;
+ this.format = format;
+ }
+
+ /** @return the instance ID to export. */
+ public String getInstanceId() {
+ return this.instanceId;
+ }
+
+ /**
+ * Sets the instance ID to export.
+ *
+ * @param instanceId the instance ID
+ */
+ public void setInstanceId(String instanceId) {
+ this.instanceId = instanceId;
+ }
+
+ /** @return the export destination. */
+ public ExportDestination getDestination() {
+ return this.destination;
+ }
+
+ /**
+ * Sets the export destination.
+ *
+ * @param destination the destination
+ */
+ public void setDestination(ExportDestination destination) {
+ this.destination = destination;
+ }
+
+ /** @return the export format. */
+ public ExportFormat getFormat() {
+ return this.format;
+ }
+
+ /**
+ * Sets the export format.
+ *
+ * @param format the format
+ */
+ public void setFormat(ExportFormat format) {
+ this.format = format;
+ }
+}
+
+/**
+ * Output of {@link ExportInstanceHistoryActivity}: whether a single instance's history export succeeded, and the
+ * blob name written (or the error on failure).
+ */
+final class ExportResult {
+
+ private String instanceId;
+ private boolean success;
+ private String error;
+ private String blobName;
+
+ /** Creates an empty {@code ExportResult} (for deserialization). */
+ public ExportResult() {
+ }
+
+ /**
+ * Creates a successful {@code ExportResult}.
+ *
+ * @param instanceId the exported instance ID
+ * @param blobName the blob name written
+ * @return a success result
+ */
+ public static ExportResult success(String instanceId, String blobName) {
+ ExportResult result = new ExportResult();
+ result.instanceId = instanceId;
+ result.success = true;
+ result.blobName = blobName;
+ return result;
+ }
+
+ /**
+ * Creates a failed {@code ExportResult}.
+ *
+ * @param instanceId the instance ID that failed
+ * @param error the error message
+ * @return a failure result
+ */
+ public static ExportResult failure(String instanceId, String error) {
+ ExportResult result = new ExportResult();
+ result.instanceId = instanceId;
+ result.success = false;
+ result.error = error;
+ return result;
+ }
+
+ /** @return the instance ID. */
+ public String getInstanceId() {
+ return this.instanceId;
+ }
+
+ /**
+ * Sets the instance ID.
+ *
+ * @param instanceId the instance ID
+ */
+ public void setInstanceId(String instanceId) {
+ this.instanceId = instanceId;
+ }
+
+ /** @return {@code true} if the export succeeded. */
+ public boolean isSuccess() {
+ return this.success;
+ }
+
+ /**
+ * Sets whether the export succeeded.
+ *
+ * @param success {@code true} if successful
+ */
+ public void setSuccess(boolean success) {
+ this.success = success;
+ }
+
+ /** @return the error message, or {@code null} on success. */
+ @Nullable
+ public String getError() {
+ return this.error;
+ }
+
+ /**
+ * Sets the error message.
+ *
+ * @param error the error message, or {@code null}
+ */
+ public void setError(@Nullable String error) {
+ this.error = error;
+ }
+
+ /** @return the blob name written, or {@code null} on failure. */
+ @Nullable
+ public String getBlobName() {
+ return this.blobName;
+ }
+
+ /**
+ * Sets the blob name written.
+ *
+ * @param blobName the blob name, or {@code null}
+ */
+ public void setBlobName(@Nullable String blobName) {
+ this.blobName = blobName;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java
new file mode 100644
index 00000000..24da772b
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java
@@ -0,0 +1,209 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.AbstractTaskEntity;
+import com.microsoft.durabletask.NewOrchestrationInstanceOptions;
+import com.microsoft.durabletask.TaskEntityOperation;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+/**
+ * Durable entity that manages a history export job: lifecycle, configuration, checkpoint, and progress.
+ *
+ * Operations are dispatched by method name (case-insensitive): {@code Create}, {@code Get}, {@code Run},
+ * {@code CommitCheckpoint}, {@code MarkAsCompleted}, {@code MarkAsFailed}, {@code Delete}.
+ */
+final class ExportJob extends AbstractTaskEntity {
+
+ /** The registered entity name. */
+ public static final String NAME = "ExportJob";
+
+ private static final Logger LOGGER = Logger.getLogger(ExportJob.class.getName());
+
+ @Override
+ protected Class getStateType() {
+ return ExportJobState.class;
+ }
+
+ @Override
+ protected ExportJobState initializeState(TaskEntityOperation operation) {
+ ExportJobState state = new ExportJobState();
+ state.setStatus(ExportJobStatus.PENDING);
+ return state;
+ }
+
+ /**
+ * Creates the export job from creation options and signals the {@code Run} operation to start the export.
+ *
+ * @param creationOptions the creation options (with destination populated by the client)
+ */
+ public void create(ExportJobCreationOptions creationOptions) {
+ if (creationOptions == null) {
+ throw new IllegalArgumentException("creationOptions must not be null.");
+ }
+ if (!ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_CREATE, this.state.getStatus(), ExportJobStatus.ACTIVE)) {
+ throw new ExportJobInvalidTransitionException(
+ creationOptions.getJobId(),
+ this.state.getStatus(),
+ ExportJobStatus.ACTIVE,
+ ExportJobTransitions.OP_CREATE);
+ }
+ if (creationOptions.getDestination() == null) {
+ throw new IllegalStateException("Export destination must be populated before reaching the entity.");
+ }
+
+ List statuses =
+ creationOptions.getRuntimeStatus() == null
+ ? null
+ : new ArrayList<>(creationOptions.getRuntimeStatus());
+
+ Instant now = Instant.now();
+ Instant completedTimeFrom = creationOptions.getCompletedTimeFrom() != null
+ ? creationOptions.getCompletedTimeFrom()
+ : now;
+ ExportFilter filter = new ExportFilter(
+ completedTimeFrom,
+ creationOptions.getCompletedTimeTo(),
+ statuses);
+ ExportJobConfiguration config = new ExportJobConfiguration(
+ creationOptions.getMode(),
+ filter,
+ creationOptions.getDestination(),
+ creationOptions.getFormat(),
+ creationOptions.getMaxInstancesPerBatch());
+
+ this.state.setConfig(config);
+ this.state.setStatus(ExportJobStatus.ACTIVE);
+ this.state.setCreatedAt(now);
+ this.state.setLastModifiedAt(now);
+ this.state.setLastError(null);
+ this.state.setScannedInstances(0);
+ this.state.setExportedInstances(0);
+ this.state.setCheckpoint(null);
+ this.state.setLastCheckpointTime(null);
+
+ // Signal Run to start the export orchestration.
+ this.context.signalEntity(this.context.getId(), ExportJobTransitions.OP_RUN);
+ LOGGER.log(Level.INFO, "Created export job {0}", creationOptions.getJobId());
+ }
+
+ /**
+ * Gets the current state of the export job.
+ *
+ * @return the current export job state
+ */
+ public ExportJobState get() {
+ return this.state;
+ }
+
+ /**
+ * Starts the export orchestration. Requires the job to be in the {@link ExportJobStatus#ACTIVE} state.
+ */
+ public void run() {
+ if (this.state.getConfig() == null) {
+ throw new IllegalStateException("Export job configuration must be set before running.");
+ }
+ if (this.state.getStatus() != ExportJobStatus.ACTIVE) {
+ throw new IllegalStateException("Export job must be in ACTIVE status to run.");
+ }
+
+ try {
+ String instanceId = ExportHistoryConstants.getOrchestratorInstanceId(this.context.getId().getKey());
+ this.context.startNewOrchestration(
+ ExportJobOrchestrator.NAME,
+ new ExportJobRunRequest(this.context.getId(), 0),
+ new NewOrchestrationInstanceOptions().setInstanceId(instanceId));
+ this.state.setOrchestratorInstanceId(instanceId);
+ this.state.setLastModifiedAt(Instant.now());
+ } catch (RuntimeException ex) {
+ this.state.setStatus(ExportJobStatus.FAILED);
+ this.state.setLastError(ex.getMessage());
+ this.state.setLastModifiedAt(Instant.now());
+ LOGGER.log(Level.WARNING, "Failed to start export orchestration for job "
+ + this.context.getId().getKey(), ex);
+ }
+ }
+
+ /**
+ * Commits a checkpoint snapshot with progress updates and optional failures.
+ *
+ * @param request the checkpoint commit request
+ */
+ public void commitCheckpoint(CommitCheckpointRequest request) {
+ if (request == null) {
+ throw new IllegalArgumentException("request must not be null.");
+ }
+
+ this.state.setScannedInstances(this.state.getScannedInstances() + request.getScannedInstances());
+ this.state.setExportedInstances(this.state.getExportedInstances() + request.getExportedInstances());
+
+ if (request.getCheckpoint() != null) {
+ this.state.setCheckpoint(request.getCheckpoint());
+ }
+
+ Instant now = Instant.now();
+ this.state.setLastCheckpointTime(now);
+ this.state.setLastModifiedAt(now);
+
+ if (request.getCheckpoint() == null
+ && request.getFailures() != null
+ && !request.getFailures().isEmpty()) {
+ this.state.setStatus(ExportJobStatus.FAILED);
+ String failureSummary = request.getFailures().stream()
+ .map(f -> f.getInstanceId() + ": " + f.getReason())
+ .collect(Collectors.joining("; "));
+ this.state.setLastError("Batch export failed after retries. Failures: " + failureSummary);
+ }
+ }
+
+ /**
+ * Marks the export job as completed. Requires the job to be in the {@link ExportJobStatus#ACTIVE} state.
+ */
+ public void markAsCompleted() {
+ if (!ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_MARK_AS_COMPLETED, this.state.getStatus(), ExportJobStatus.COMPLETED)) {
+ throw new ExportJobInvalidTransitionException(
+ this.context.getId().getKey(),
+ this.state.getStatus(),
+ ExportJobStatus.COMPLETED,
+ ExportJobTransitions.OP_MARK_AS_COMPLETED);
+ }
+ this.state.setStatus(ExportJobStatus.COMPLETED);
+ this.state.setLastModifiedAt(Instant.now());
+ this.state.setLastError(null);
+ }
+
+ /**
+ * Marks the export job as failed. Requires the job to be in the {@link ExportJobStatus#ACTIVE} state.
+ *
+ * @param errorMessage the error message describing why the job failed
+ */
+ public void markAsFailed(String errorMessage) {
+ if (!ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_MARK_AS_FAILED, this.state.getStatus(), ExportJobStatus.FAILED)) {
+ throw new ExportJobInvalidTransitionException(
+ this.context.getId().getKey(),
+ this.state.getStatus(),
+ ExportJobStatus.FAILED,
+ ExportJobTransitions.OP_MARK_AS_FAILED);
+ }
+ this.state.setStatus(ExportJobStatus.FAILED);
+ this.state.setLastError(errorMessage);
+ this.state.setLastModifiedAt(Instant.now());
+ }
+
+ /**
+ * Deletes the export job entity by clearing its state. Deleting an active job stops its orchestrator, which
+ * exits on its next cycle when it observes the job is no longer active.
+ */
+ public void delete() {
+ this.state = null;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobClientValidationException.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobClientValidationException.java
new file mode 100644
index 00000000..a7f4f106
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobClientValidationException.java
@@ -0,0 +1,30 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+/**
+ * Thrown when export job creation options or client arguments fail validation.
+ */
+public final class ExportJobClientValidationException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates a new {@code ExportJobClientValidationException}.
+ *
+ * @param message the validation error message
+ */
+ public ExportJobClientValidationException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates a new {@code ExportJobClientValidationException} with a cause.
+ *
+ * @param message the validation error message
+ * @param cause the underlying cause
+ */
+ public ExportJobClientValidationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobConfiguration.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobConfiguration.java
new file mode 100644
index 00000000..097f6a00
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobConfiguration.java
@@ -0,0 +1,132 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+/**
+ * Configuration for an export job, persisted in the {@link ExportJobState} entity state.
+ */
+public final class ExportJobConfiguration {
+
+ /** The default maximum number of parallel export operations. */
+ public static final int DEFAULT_MAX_PARALLEL_EXPORTS = 32;
+
+ /** The default maximum number of instances fetched per batch. */
+ public static final int DEFAULT_MAX_INSTANCES_PER_BATCH = 100;
+
+ private ExportMode mode;
+ private ExportFilter filter;
+ private ExportDestination destination;
+ private ExportFormat format;
+ private int maxParallelExports = DEFAULT_MAX_PARALLEL_EXPORTS;
+ private int maxInstancesPerBatch = DEFAULT_MAX_INSTANCES_PER_BATCH;
+
+ /** Creates an empty {@code ExportJobConfiguration} (for deserialization). */
+ public ExportJobConfiguration() {
+ }
+
+ /**
+ * Creates an {@code ExportJobConfiguration}.
+ *
+ * @param mode the export mode
+ * @param filter the filter criteria
+ * @param destination the export destination
+ * @param format the export format
+ * @param maxInstancesPerBatch the maximum instances fetched per batch
+ */
+ public ExportJobConfiguration(
+ ExportMode mode,
+ ExportFilter filter,
+ ExportDestination destination,
+ ExportFormat format,
+ int maxInstancesPerBatch) {
+ this.mode = mode;
+ this.filter = filter;
+ this.destination = destination;
+ this.format = format;
+ this.maxInstancesPerBatch = maxInstancesPerBatch;
+ }
+
+ /** @return the export mode. */
+ public ExportMode getMode() {
+ return this.mode;
+ }
+
+ /**
+ * Sets the export mode.
+ *
+ * @param mode the export mode
+ */
+ public void setMode(ExportMode mode) {
+ this.mode = mode;
+ }
+
+ /** @return the filter criteria. */
+ public ExportFilter getFilter() {
+ return this.filter;
+ }
+
+ /**
+ * Sets the filter criteria.
+ *
+ * @param filter the filter criteria
+ */
+ public void setFilter(ExportFilter filter) {
+ this.filter = filter;
+ }
+
+ /** @return the export destination. */
+ public ExportDestination getDestination() {
+ return this.destination;
+ }
+
+ /**
+ * Sets the export destination.
+ *
+ * @param destination the destination
+ */
+ public void setDestination(ExportDestination destination) {
+ this.destination = destination;
+ }
+
+ /** @return the export format. */
+ public ExportFormat getFormat() {
+ return this.format;
+ }
+
+ /**
+ * Sets the export format.
+ *
+ * @param format the format
+ */
+ public void setFormat(ExportFormat format) {
+ this.format = format;
+ }
+
+ /** @return the maximum number of parallel export operations. */
+ public int getMaxParallelExports() {
+ return this.maxParallelExports;
+ }
+
+ /**
+ * Sets the maximum number of parallel export operations.
+ *
+ * @param maxParallelExports the maximum parallel exports
+ */
+ public void setMaxParallelExports(int maxParallelExports) {
+ this.maxParallelExports = maxParallelExports;
+ }
+
+ /** @return the maximum number of instances fetched per batch. */
+ public int getMaxInstancesPerBatch() {
+ return this.maxInstancesPerBatch;
+ }
+
+ /**
+ * Sets the maximum number of instances fetched per batch.
+ *
+ * @param maxInstancesPerBatch the maximum instances per batch
+ */
+ public void setMaxInstancesPerBatch(int maxInstancesPerBatch) {
+ this.maxInstancesPerBatch = maxInstancesPerBatch;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java
new file mode 100644
index 00000000..72411e13
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java
@@ -0,0 +1,247 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+/**
+ * Configuration for creating an export job.
+ *
+ * Export supports terminal orchestration statuses only ({@link OrchestrationRuntimeStatus#COMPLETED},
+ * {@link OrchestrationRuntimeStatus#FAILED}, {@link OrchestrationRuntimeStatus#TERMINATED}); when no status filter
+ * is supplied, all three are exported.
+ *
+ *
{@code
+ * export.createJob(new ExportJobCreationOptions("nightly-archive")
+ * .setMode(ExportMode.BATCH)
+ * .setCompletedTimeFrom(Instant.parse("2026-06-01T00:00:00Z"))
+ * .setCompletedTimeTo(Instant.parse("2026-06-25T00:00:00Z"))
+ * .setRuntimeStatus(Collections.singletonList(OrchestrationRuntimeStatus.COMPLETED))
+ * .setMaxInstancesPerBatch(200));
+ * }
+ */
+public final class ExportJobCreationOptions {
+
+ /** The lowest valid value for {@link #setMaxInstancesPerBatch(int)}. */
+ public static final int MIN_INSTANCES_PER_BATCH = 1;
+
+ /** The highest valid value for {@link #setMaxInstancesPerBatch(int)}. */
+ public static final int MAX_INSTANCES_PER_BATCH = 1000;
+
+ private static final List TERMINAL_STATUSES = Collections.unmodifiableList(
+ Arrays.asList(
+ OrchestrationRuntimeStatus.COMPLETED,
+ OrchestrationRuntimeStatus.FAILED,
+ OrchestrationRuntimeStatus.TERMINATED));
+
+ private final String jobId;
+ private ExportMode mode = ExportMode.BATCH;
+ private Instant completedTimeFrom;
+ private Instant completedTimeTo;
+ private List runtimeStatus = new ArrayList<>(TERMINAL_STATUSES);
+ private int maxInstancesPerBatch = 100;
+ private ExportFormat format = ExportFormat.getDefault();
+ private ExportDestination destination;
+
+ /**
+ * Creates a new {@code ExportJobCreationOptions} with the given job ID.
+ *
+ * @param jobId the unique export job ID; if {@code null} or empty, a random ID is generated
+ */
+ @JsonCreator
+ public ExportJobCreationOptions(@JsonProperty("jobId") @Nullable String jobId) {
+ this.jobId = (jobId == null || jobId.isEmpty()) ? UUID.randomUUID().toString().replace("-", "") : jobId;
+ }
+
+ /** @return the export job ID. */
+ public String getJobId() {
+ return this.jobId;
+ }
+
+ /** @return the export mode. */
+ public ExportMode getMode() {
+ return this.mode;
+ }
+
+ /**
+ * Sets the export mode (default {@link ExportMode#BATCH}).
+ *
+ * @param mode the export mode
+ * @return this options object
+ */
+ public ExportJobCreationOptions setMode(ExportMode mode) {
+ if (mode == null) {
+ throw new IllegalArgumentException("mode must not be null.");
+ }
+ this.mode = mode;
+ return this;
+ }
+
+ /** @return the inclusive completion-time lower bound, or {@code null} if not set. */
+ @Nullable
+ public Instant getCompletedTimeFrom() {
+ return this.completedTimeFrom;
+ }
+
+ /**
+ * Sets the inclusive completion-time lower bound. Required for {@link ExportMode#BATCH}; for
+ * {@link ExportMode#CONTINUOUS} it defaults to the job creation time when omitted.
+ *
+ * @param completedTimeFrom the lower bound, or {@code null} to clear
+ * @return this options object
+ */
+ public ExportJobCreationOptions setCompletedTimeFrom(@Nullable Instant completedTimeFrom) {
+ this.completedTimeFrom = completedTimeFrom;
+ return this;
+ }
+
+ /** @return the inclusive completion-time upper bound, or {@code null} if not set. */
+ @Nullable
+ public Instant getCompletedTimeTo() {
+ return this.completedTimeTo;
+ }
+
+ /**
+ * Sets the inclusive completion-time upper bound. Required for {@link ExportMode#BATCH}; not allowed for
+ * {@link ExportMode#CONTINUOUS}.
+ *
+ * @param completedTimeTo the upper bound, or {@code null} to clear
+ * @return this options object
+ */
+ public ExportJobCreationOptions setCompletedTimeTo(@Nullable Instant completedTimeTo) {
+ this.completedTimeTo = completedTimeTo;
+ return this;
+ }
+
+ /** @return an unmodifiable view of the terminal runtime statuses to export. */
+ public List getRuntimeStatus() {
+ return Collections.unmodifiableList(this.runtimeStatus);
+ }
+
+ /**
+ * Sets the terminal runtime statuses to filter by. Only {@link OrchestrationRuntimeStatus#COMPLETED},
+ * {@link OrchestrationRuntimeStatus#FAILED}, and {@link OrchestrationRuntimeStatus#TERMINATED} are permitted.
+ * Passing {@code null} or an empty list resets to all terminal statuses.
+ *
+ * @param runtimeStatus the terminal runtime statuses, or {@code null}/empty for all terminal statuses
+ * @return this options object
+ * @throws IllegalArgumentException if any status is non-terminal
+ */
+ public ExportJobCreationOptions setRuntimeStatus(@Nullable List runtimeStatus) {
+ if (runtimeStatus == null || runtimeStatus.isEmpty()) {
+ this.runtimeStatus = new ArrayList<>(TERMINAL_STATUSES);
+ return this;
+ }
+ for (OrchestrationRuntimeStatus status : runtimeStatus) {
+ if (!TERMINAL_STATUSES.contains(status)) {
+ throw new IllegalArgumentException(
+ "Export supports terminal orchestration statuses only. Valid statuses are: "
+ + "COMPLETED, FAILED, and TERMINATED.");
+ }
+ }
+ this.runtimeStatus = new ArrayList<>(runtimeStatus);
+ return this;
+ }
+
+ /** @return the maximum number of instances fetched per batch. */
+ public int getMaxInstancesPerBatch() {
+ return this.maxInstancesPerBatch;
+ }
+ /**
+ * Sets the maximum number of instances to fetch per batch (default 100).
+ *
+ * @param maxInstancesPerBatch a value in the range [{@value #MIN_INSTANCES_PER_BATCH},
+ * {@value #MAX_INSTANCES_PER_BATCH}]
+ * @return this options object
+ * @throws IllegalArgumentException if the value is out of range
+ */
+ public ExportJobCreationOptions setMaxInstancesPerBatch(int maxInstancesPerBatch) {
+ if (maxInstancesPerBatch < MIN_INSTANCES_PER_BATCH || maxInstancesPerBatch > MAX_INSTANCES_PER_BATCH) {
+ throw new IllegalArgumentException(
+ "MaxInstancesPerBatch must be between " + MIN_INSTANCES_PER_BATCH + " and "
+ + MAX_INSTANCES_PER_BATCH + ".");
+ }
+ this.maxInstancesPerBatch = maxInstancesPerBatch;
+ return this;
+ }
+
+ /** @return the export format (default JSONL + gzip). */
+ public ExportFormat getFormat() {
+ return this.format;
+ }
+
+ /**
+ * Sets the export format.
+ *
+ * @param format the export format
+ * @return this options object
+ */
+ public ExportJobCreationOptions setFormat(ExportFormat format) {
+ if (format == null) {
+ throw new IllegalArgumentException("format must not be null.");
+ }
+ this.format = format;
+ return this;
+ }
+
+ /**
+ * Gets the export destination. This is populated by the client from its registered
+ * {@link ExportHistoryStorageOptions} before the job is created; callers do not normally set it.
+ *
+ * @return the export destination, or {@code null} if not yet populated
+ */
+ @Nullable
+ public ExportDestination getDestination() {
+ return this.destination;
+ }
+
+ /**
+ * Sets the export destination. Normally called by the client, not the user.
+ *
+ * @param destination the export destination
+ * @return this options object
+ */
+ public ExportJobCreationOptions setDestination(@Nullable ExportDestination destination) {
+ this.destination = destination;
+ return this;
+ }
+
+ /**
+ * Validates mode-specific completion-window rules. Intended to be called
+ * by the client at job-creation time.
+ *
+ * @throws IllegalArgumentException if the configuration is invalid for the selected mode
+ */
+ public void validateForCreate() {
+ if (this.mode == ExportMode.BATCH) {
+ if (this.completedTimeFrom == null) {
+ throw new IllegalArgumentException("CompletedTimeFrom is required for BATCH export mode.");
+ }
+ if (this.completedTimeTo == null) {
+ throw new IllegalArgumentException("CompletedTimeTo is required for BATCH export mode.");
+ }
+ if (!this.completedTimeTo.isAfter(this.completedTimeFrom)) {
+ throw new IllegalArgumentException(
+ "CompletedTimeTo must be greater than CompletedTimeFrom for BATCH export mode.");
+ }
+ if (this.completedTimeTo.isAfter(Instant.now())) {
+ throw new IllegalArgumentException("CompletedTimeTo cannot be in the future.");
+ }
+ } else if (this.mode == ExportMode.CONTINUOUS) {
+ if (this.completedTimeTo != null) {
+ throw new IllegalArgumentException("CompletedTimeTo is not allowed for CONTINUOUS export mode.");
+ }
+ } else {
+ throw new IllegalArgumentException("Invalid export mode.");
+ }
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobDescription.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobDescription.java
new file mode 100644
index 00000000..3f7d16d2
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobDescription.java
@@ -0,0 +1,212 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * Client-facing description of an export job, projected from the entity {@link ExportJobState}.
+ */
+public final class ExportJobDescription {
+
+ private String jobId = "";
+ private ExportJobStatus status;
+ private Instant createdAt;
+ private Instant lastModifiedAt;
+ private ExportJobConfiguration config;
+ private String orchestratorInstanceId;
+ private long scannedInstances;
+ private long exportedInstances;
+ private String lastError;
+ private ExportCheckpoint checkpoint;
+ private Instant lastCheckpointTime;
+
+ /** Creates an empty {@code ExportJobDescription}. */
+ public ExportJobDescription() {
+ }
+
+ /**
+ * Projects an {@link ExportJobState} into a client-facing description.
+ *
+ * @param jobId the export job ID
+ * @param state the entity state
+ * @return the projected description
+ */
+ public static ExportJobDescription fromState(String jobId, ExportJobState state) {
+ ExportJobDescription d = new ExportJobDescription();
+ d.jobId = jobId;
+ d.status = state.getStatus();
+ d.createdAt = state.getCreatedAt();
+ d.lastModifiedAt = state.getLastModifiedAt();
+ d.config = state.getConfig();
+ d.orchestratorInstanceId = state.getOrchestratorInstanceId();
+ d.scannedInstances = state.getScannedInstances();
+ d.exportedInstances = state.getExportedInstances();
+ d.lastError = state.getLastError();
+ d.checkpoint = state.getCheckpoint();
+ d.lastCheckpointTime = state.getLastCheckpointTime();
+ return d;
+ }
+
+ /** @return the job identifier. */
+ public String getJobId() {
+ return this.jobId;
+ }
+
+ /**
+ * Sets the job identifier.
+ *
+ * @param jobId the job ID
+ */
+ public void setJobId(String jobId) {
+ this.jobId = jobId;
+ }
+
+ /** @return the export job status. */
+ public ExportJobStatus getStatus() {
+ return this.status;
+ }
+
+ /**
+ * Sets the export job status.
+ *
+ * @param status the status
+ */
+ public void setStatus(ExportJobStatus status) {
+ this.status = status;
+ }
+
+ /** @return the creation time, or {@code null}. */
+ @Nullable
+ public Instant getCreatedAt() {
+ return this.createdAt;
+ }
+
+ /**
+ * Sets the creation time.
+ *
+ * @param createdAt the creation time
+ */
+ public void setCreatedAt(@Nullable Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ /** @return the last-modified time, or {@code null}. */
+ @Nullable
+ public Instant getLastModifiedAt() {
+ return this.lastModifiedAt;
+ }
+
+ /**
+ * Sets the last-modified time.
+ *
+ * @param lastModifiedAt the last-modified time
+ */
+ public void setLastModifiedAt(@Nullable Instant lastModifiedAt) {
+ this.lastModifiedAt = lastModifiedAt;
+ }
+
+ /** @return the export job configuration, or {@code null}. */
+ @Nullable
+ public ExportJobConfiguration getConfig() {
+ return this.config;
+ }
+
+ /**
+ * Sets the export job configuration.
+ *
+ * @param config the configuration
+ */
+ public void setConfig(@Nullable ExportJobConfiguration config) {
+ this.config = config;
+ }
+
+ /** @return the running orchestrator instance ID, or {@code null}. */
+ @Nullable
+ public String getOrchestratorInstanceId() {
+ return this.orchestratorInstanceId;
+ }
+
+ /**
+ * Sets the running orchestrator instance ID.
+ *
+ * @param orchestratorInstanceId the orchestrator instance ID
+ */
+ public void setOrchestratorInstanceId(@Nullable String orchestratorInstanceId) {
+ this.orchestratorInstanceId = orchestratorInstanceId;
+ }
+
+ /** @return the total number of instances scanned. */
+ public long getScannedInstances() {
+ return this.scannedInstances;
+ }
+
+ /**
+ * Sets the total number of instances scanned.
+ *
+ * @param scannedInstances the scanned count
+ */
+ public void setScannedInstances(long scannedInstances) {
+ this.scannedInstances = scannedInstances;
+ }
+
+ /** @return the total number of instances exported. */
+ public long getExportedInstances() {
+ return this.exportedInstances;
+ }
+
+ /**
+ * Sets the total number of instances exported.
+ *
+ * @param exportedInstances the exported count
+ */
+ public void setExportedInstances(long exportedInstances) {
+ this.exportedInstances = exportedInstances;
+ }
+
+ /** @return the last error message, or {@code null}. */
+ @Nullable
+ public String getLastError() {
+ return this.lastError;
+ }
+
+ /**
+ * Sets the last error message.
+ *
+ * @param lastError the error message
+ */
+ public void setLastError(@Nullable String lastError) {
+ this.lastError = lastError;
+ }
+
+ /** @return the resume checkpoint, or {@code null}. */
+ @Nullable
+ public ExportCheckpoint getCheckpoint() {
+ return this.checkpoint;
+ }
+
+ /**
+ * Sets the resume checkpoint.
+ *
+ * @param checkpoint the checkpoint
+ */
+ public void setCheckpoint(@Nullable ExportCheckpoint checkpoint) {
+ this.checkpoint = checkpoint;
+ }
+
+ /** @return the time of the last checkpoint, or {@code null}. */
+ @Nullable
+ public Instant getLastCheckpointTime() {
+ return this.lastCheckpointTime;
+ }
+
+ /**
+ * Sets the time of the last checkpoint.
+ *
+ * @param lastCheckpointTime the last checkpoint time
+ */
+ public void setLastCheckpointTime(@Nullable Instant lastCheckpointTime) {
+ this.lastCheckpointTime = lastCheckpointTime;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobInvalidTransitionException.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobInvalidTransitionException.java
new file mode 100644
index 00000000..8550be59
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobInvalidTransitionException.java
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+/**
+ * Thrown when an export job operation attempts an invalid status transition.
+ */
+public final class ExportJobInvalidTransitionException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String jobId;
+ private final ExportJobStatus fromStatus;
+ private final ExportJobStatus toStatus;
+ private final String operationName;
+
+ /**
+ * Creates a new {@code ExportJobInvalidTransitionException}.
+ *
+ * @param jobId the export job ID
+ * @param fromStatus the current status
+ * @param toStatus the attempted target status
+ * @param operationName the operation that attempted the transition
+ */
+ public ExportJobInvalidTransitionException(
+ String jobId,
+ ExportJobStatus fromStatus,
+ ExportJobStatus toStatus,
+ String operationName) {
+ super("Export job '" + jobId + "' cannot transition from " + fromStatus + " to " + toStatus
+ + " via operation '" + operationName + "'.");
+ this.jobId = jobId;
+ this.fromStatus = fromStatus;
+ this.toStatus = toStatus;
+ this.operationName = operationName;
+ }
+
+ /** @return the export job ID. */
+ public String getJobId() {
+ return this.jobId;
+ }
+
+ /** @return the current status. */
+ public ExportJobStatus getFromStatus() {
+ return this.fromStatus;
+ }
+
+ /** @return the attempted target status. */
+ public ExportJobStatus getToStatus() {
+ return this.toStatus;
+ }
+
+ /** @return the operation that attempted the transition. */
+ public String getOperationName() {
+ return this.operationName;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobNotFoundException.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobNotFoundException.java
new file mode 100644
index 00000000..8cecefb2
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobNotFoundException.java
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+/**
+ * Thrown when an export job cannot be found.
+ */
+public final class ExportJobNotFoundException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String jobId;
+
+ /**
+ * Creates a new {@code ExportJobNotFoundException}.
+ *
+ * @param jobId the export job ID that was not found
+ */
+ public ExportJobNotFoundException(String jobId) {
+ super("Export job '" + jobId + "' was not found.");
+ this.jobId = jobId;
+ }
+
+ /** @return the export job ID that was not found. */
+ public String getJobId() {
+ return this.jobId;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java
new file mode 100644
index 00000000..a8a0f8ba
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java
@@ -0,0 +1,334 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.EntityInstanceId;
+import com.microsoft.durabletask.RetryPolicy;
+import com.microsoft.durabletask.Task;
+import com.microsoft.durabletask.TaskOptions;
+import com.microsoft.durabletask.TaskOrchestration;
+import com.microsoft.durabletask.TaskOrchestrationContext;
+import com.microsoft.durabletask.interruption.ContinueAsNewInterruption;
+import com.microsoft.durabletask.interruption.OrchestratorBlockedException;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+/**
+ * Orchestrator that performs the export work: it pages terminal instances, fans out per-instance export activities,
+ * commits checkpoints to the {@link ExportJob} entity, and handles BATCH vs CONTINUOUS modes with bounded retries
+ * and periodic {@code continueAsNew}.
+ */
+final class ExportJobOrchestrator implements TaskOrchestration {
+
+ /** The registered orchestration name. */
+ public static final String NAME = "ExportJobOrchestrator";
+
+ private static final Logger LOGGER = Logger.getLogger(ExportJobOrchestrator.class.getName());
+
+ private static final int MAX_RETRY_ATTEMPTS = 3;
+ private static final int MIN_BACKOFF_SECONDS = 60;
+ private static final int MAX_BACKOFF_SECONDS = 300;
+ private static final int CONTINUE_AS_NEW_FREQUENCY = 5;
+ private static final Duration CONTINUOUS_EXPORT_IDLE_DELAY = Duration.ofMinutes(1);
+
+ private static final TaskOptions EXPORT_ACTIVITY_RETRY_OPTIONS = new TaskOptions(
+ new RetryPolicy(3, Duration.ofSeconds(15))
+ .setBackoffCoefficient(2.0)
+ .setMaxRetryInterval(Duration.ofSeconds(60)));
+
+ @Override
+ public void run(TaskOrchestrationContext ctx) {
+ ExportJobRunRequest input = ctx.getInput(ExportJobRunRequest.class);
+ EntityInstanceId jobEntityId = input.getJobEntityId();
+ String jobId = jobEntityId.getKey();
+ log(ctx, Level.INFO, "Export orchestrator started for job " + jobId);
+
+ try {
+ ExportJobState jobState = callGet(ctx, jobEntityId);
+ if (jobState == null || jobState.getConfig() == null) {
+ throw new IllegalStateException(
+ "Export job '" + jobEntityId.getKey() + "' not found or has no configuration.");
+ }
+ if (jobState.getStatus() != ExportJobStatus.ACTIVE) {
+ // Job is no longer active (deleted, completed, or failed) - nothing to do.
+ return;
+ }
+
+ ExportJobConfiguration config = jobState.getConfig();
+ int processedCycles = input.getProcessedCycles();
+
+ while (true) {
+ processedCycles++;
+ if (processedCycles > CONTINUE_AS_NEW_FREQUENCY) {
+ ctx.continueAsNew(new ExportJobRunRequest(jobEntityId, 0));
+ return;
+ }
+
+ ExportJobState currentState = callGet(ctx, jobEntityId);
+ if (currentState == null
+ || currentState.getConfig() == null
+ || currentState.getStatus() != ExportJobStatus.ACTIVE) {
+ return;
+ }
+
+ ExportFilter filter = currentState.getConfig().getFilter();
+ String lastInstanceKey = currentState.getCheckpoint() == null
+ ? null
+ : currentState.getCheckpoint().getLastInstanceKey();
+ ListTerminalInstancesRequest listRequest = new ListTerminalInstancesRequest(
+ filter.getCompletedTimeFrom(),
+ filter.getCompletedTimeTo(),
+ filter.getRuntimeStatus(),
+ lastInstanceKey,
+ currentState.getConfig().getMaxInstancesPerBatch());
+
+ InstancePage pageResult = ctx.callActivity(
+ ListTerminalInstancesActivity.NAME, listRequest, InstancePage.class).await();
+
+ List instancesToExport = pageResult.getInstanceIds();
+ long scannedCount = instancesToExport.size();
+
+ if (scannedCount == 0) {
+ if (config.getMode() == ExportMode.CONTINUOUS) {
+ ctx.createTimer(CONTINUOUS_EXPORT_IDLE_DELAY).await();
+ continue;
+ } else if (config.getMode() == ExportMode.BATCH) {
+ break;
+ } else {
+ throw new IllegalStateException("Invalid export mode.");
+ }
+ }
+
+ BatchExportResult batchResult = processBatchWithRetry(ctx, instancesToExport, config);
+
+ if (batchResult.allSucceeded) {
+ commitCheckpoint(
+ ctx, jobEntityId, scannedCount, batchResult.exportedCount,
+ pageResult.getNextCheckpoint(), null);
+ log(ctx, Level.INFO, "Job " + jobId + " batch scanned=" + scannedCount
+ + " exported=" + batchResult.exportedCount);
+ } else {
+ // Commit without advancing the cursor; the entity transitions the job to FAILED.
+ commitCheckpoint(ctx, jobEntityId, 0, 0, null, batchResult.failures);
+ log(ctx, Level.WARNING, "Export job '" + jobId + "' batch export failed after "
+ + MAX_RETRY_ATTEMPTS + " retry attempts. " + summarizeFailures(batchResult.failures));
+ return;
+ }
+ }
+
+ markAsCompleted(ctx, jobEntityId);
+ log(ctx, Level.INFO, "Export orchestrator completed for job " + jobId);
+ } catch (OrchestratorBlockedException | ContinueAsNewInterruption controlFlow) {
+ // These are the SDK's control-flow signals (await yield / continueAsNew). They must never be
+ // treated as failures - rethrow so the runtime can suspend/restart the orchestration.
+ throw controlFlow;
+ } catch (RuntimeException ex) {
+ // A genuine, unexpected failure while the job is still active. Mark the job failed, then fail the
+ // orchestration. (The await inside markAsFailed may itself yield; that interruption propagates.)
+ log(ctx, Level.WARNING, "Export orchestrator failed for job " + jobId + ": " + ex.getMessage());
+ markAsFailed(ctx, jobEntityId, ex.getMessage());
+ throw ex;
+ }
+ }
+
+ private static void log(TaskOrchestrationContext ctx, Level level, String message) {
+ if (!ctx.getIsReplaying()) {
+ LOGGER.log(level, message);
+ }
+ }
+
+ private static ExportJobState callGet(TaskOrchestrationContext ctx, EntityInstanceId jobEntityId) {
+ return ctx.getEntities()
+ .callEntity(jobEntityId, ExportJobTransitions.OP_GET, null, ExportJobState.class)
+ .await();
+ }
+
+ private BatchExportResult processBatchWithRetry(
+ TaskOrchestrationContext ctx,
+ List instanceIds,
+ ExportJobConfiguration config) {
+ for (int attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
+ List results = exportBatch(ctx, instanceIds, config);
+ List failedResults = results.stream()
+ .filter(r -> !r.isSuccess())
+ .collect(Collectors.toList());
+
+ if (failedResults.isEmpty()) {
+ return BatchExportResult.succeeded(results.size());
+ }
+
+ if (attempt == MAX_RETRY_ATTEMPTS) {
+ Instant now = ctx.getCurrentInstant();
+ int finalAttempt = attempt;
+ List failures = failedResults.stream()
+ .map(r -> new ExportFailure(
+ r.getInstanceId(),
+ r.getError() == null ? "Unknown error" : r.getError(),
+ finalAttempt,
+ now))
+ .collect(Collectors.toList());
+ long exportedCount = results.stream().filter(ExportResult::isSuccess).count();
+ return BatchExportResult.failed((int) exportedCount, failures);
+ }
+
+ int backoffSeconds = Math.min(
+ MIN_BACKOFF_SECONDS * (int) Math.pow(2, attempt - 1), MAX_BACKOFF_SECONDS);
+ ctx.createTimer(Duration.ofSeconds(backoffSeconds)).await();
+ }
+
+ // Unreachable: the loop either returns success/failure or retries.
+ return BatchExportResult.succeeded(0);
+ }
+
+ private List exportBatch(
+ TaskOrchestrationContext ctx,
+ List instanceIds,
+ ExportJobConfiguration config) {
+ List results = new ArrayList<>();
+ List> exportTasks = new ArrayList<>();
+
+ for (String instanceId : instanceIds) {
+ ExportRequest exportRequest = new ExportRequest(
+ instanceId, config.getDestination(), config.getFormat());
+ exportTasks.add(ctx.callActivity(
+ ExportInstanceHistoryActivity.NAME,
+ exportRequest,
+ EXPORT_ACTIVITY_RETRY_OPTIONS,
+ ExportResult.class));
+
+ if (exportTasks.size() >= config.getMaxParallelExports()) {
+ results.addAll(ctx.allOf(exportTasks).await());
+ exportTasks.clear();
+ }
+ }
+
+ if (!exportTasks.isEmpty()) {
+ results.addAll(ctx.allOf(exportTasks).await());
+ }
+
+ return results;
+ }
+
+ private static void commitCheckpoint(
+ TaskOrchestrationContext ctx,
+ EntityInstanceId jobEntityId,
+ long scannedInstances,
+ long exportedInstances,
+ ExportCheckpoint checkpoint,
+ List failures) {
+ CommitCheckpointRequest request = new CommitCheckpointRequest();
+ request.setScannedInstances(scannedInstances);
+ request.setExportedInstances(exportedInstances);
+ request.setCheckpoint(checkpoint);
+ request.setFailures(failures);
+ ctx.getEntities()
+ .callEntity(jobEntityId, ExportJobTransitions.OP_COMMIT_CHECKPOINT, request, Void.class)
+ .await();
+ }
+
+ private static void markAsCompleted(TaskOrchestrationContext ctx, EntityInstanceId jobEntityId) {
+ ctx.getEntities()
+ .callEntity(jobEntityId, ExportJobTransitions.OP_MARK_AS_COMPLETED, null, Void.class)
+ .await();
+ }
+
+ private static void markAsFailed(
+ TaskOrchestrationContext ctx, EntityInstanceId jobEntityId, String errorMessage) {
+ ctx.getEntities()
+ .callEntity(jobEntityId, ExportJobTransitions.OP_MARK_AS_FAILED, errorMessage, Void.class)
+ .await();
+ }
+
+ private static String summarizeFailures(List failures) {
+ if (failures == null || failures.isEmpty()) {
+ return "No failure details available.";
+ }
+ String details = failures.stream()
+ .limit(10)
+ .map(f -> "InstanceId: " + f.getInstanceId() + ", Reason: " + f.getReason())
+ .collect(Collectors.joining("; "));
+ if (failures.size() > 10) {
+ details += " ... and " + (failures.size() - 10) + " more failures";
+ }
+ return "Failure details: " + details;
+ }
+
+ private static final class BatchExportResult {
+ private final boolean allSucceeded;
+ private final int exportedCount;
+ private final List failures;
+
+ private BatchExportResult(boolean allSucceeded, int exportedCount, List failures) {
+ this.allSucceeded = allSucceeded;
+ this.exportedCount = exportedCount;
+ this.failures = failures;
+ }
+
+ static BatchExportResult succeeded(int exportedCount) {
+ return new BatchExportResult(true, exportedCount, null);
+ }
+
+ static BatchExportResult failed(int exportedCount, List failures) {
+ return new BatchExportResult(false, exportedCount, failures);
+ }
+ }
+}
+
+/**
+ * Input to the {@link ExportJobOrchestrator} identifying the job entity and the number of processed cycles
+ * (used to bound work before {@code continueAsNew}).
+ */
+final class ExportJobRunRequest {
+
+ private EntityInstanceId jobEntityId;
+ private int processedCycles;
+
+ /** Creates an empty {@code ExportJobRunRequest} (for deserialization). */
+ public ExportJobRunRequest() {
+ }
+
+ /**
+ * Creates an {@code ExportJobRunRequest}.
+ *
+ * @param jobEntityId the export job entity ID
+ * @param processedCycles the number of cycles already processed in this orchestration generation
+ */
+ public ExportJobRunRequest(EntityInstanceId jobEntityId, int processedCycles) {
+ this.jobEntityId = jobEntityId;
+ this.processedCycles = processedCycles;
+ }
+
+ /** @return the export job entity ID. */
+ public EntityInstanceId getJobEntityId() {
+ return this.jobEntityId;
+ }
+
+ /**
+ * Sets the export job entity ID.
+ *
+ * @param jobEntityId the entity ID
+ */
+ public void setJobEntityId(EntityInstanceId jobEntityId) {
+ this.jobEntityId = jobEntityId;
+ }
+
+ /** @return the number of cycles already processed in this orchestration generation. */
+ public int getProcessedCycles() {
+ return this.processedCycles;
+ }
+
+ /**
+ * Sets the number of cycles already processed.
+ *
+ * @param processedCycles the processed cycle count
+ */
+ public void setProcessedCycles(int processedCycles) {
+ this.processedCycles = processedCycles;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQuery.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQuery.java
new file mode 100644
index 00000000..e888f3e6
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQuery.java
@@ -0,0 +1,128 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * Query parameters for filtering export history jobs via {@link ExportHistoryClient#listJobs(ExportJobQuery)}.
+ */
+public final class ExportJobQuery {
+
+ /** The default page size when not supplied. */
+ public static final int DEFAULT_PAGE_SIZE = 100;
+
+ private ExportJobStatus status;
+ private String jobIdPrefix;
+ private Instant createdFrom;
+ private Instant createdTo;
+ private Integer pageSize;
+ private String continuationToken;
+
+ /** Creates an empty {@code ExportJobQuery}. */
+ public ExportJobQuery() {
+ }
+
+ /** @return the status filter, or {@code null}. */
+ @Nullable
+ public ExportJobStatus getStatus() {
+ return this.status;
+ }
+
+ /**
+ * Sets the status filter.
+ *
+ * @param status the status filter, or {@code null}
+ * @return this query object
+ */
+ public ExportJobQuery setStatus(@Nullable ExportJobStatus status) {
+ this.status = status;
+ return this;
+ }
+
+ /** @return the job-ID prefix filter, or {@code null}. */
+ @Nullable
+ public String getJobIdPrefix() {
+ return this.jobIdPrefix;
+ }
+
+ /**
+ * Sets the job-ID prefix filter.
+ *
+ * @param jobIdPrefix the prefix, or {@code null}
+ * @return this query object
+ */
+ public ExportJobQuery setJobIdPrefix(@Nullable String jobIdPrefix) {
+ this.jobIdPrefix = jobIdPrefix;
+ return this;
+ }
+
+ /** @return the created-after filter, or {@code null}. */
+ @Nullable
+ public Instant getCreatedFrom() {
+ return this.createdFrom;
+ }
+
+ /**
+ * Sets the created-after filter.
+ *
+ * @param createdFrom the lower bound, or {@code null}
+ * @return this query object
+ */
+ public ExportJobQuery setCreatedFrom(@Nullable Instant createdFrom) {
+ this.createdFrom = createdFrom;
+ return this;
+ }
+
+ /** @return the created-before filter, or {@code null}. */
+ @Nullable
+ public Instant getCreatedTo() {
+ return this.createdTo;
+ }
+
+ /**
+ * Sets the created-before filter.
+ *
+ * @param createdTo the upper bound, or {@code null}
+ * @return this query object
+ */
+ public ExportJobQuery setCreatedTo(@Nullable Instant createdTo) {
+ this.createdTo = createdTo;
+ return this;
+ }
+
+ /** @return the page size, or {@code null} for the default. */
+ @Nullable
+ public Integer getPageSize() {
+ return this.pageSize;
+ }
+
+ /**
+ * Sets the maximum number of jobs to return per page.
+ *
+ * @param pageSize the page size, or {@code null} for the default
+ * @return this query object
+ */
+ public ExportJobQuery setPageSize(@Nullable Integer pageSize) {
+ this.pageSize = pageSize;
+ return this;
+ }
+
+ /** @return the continuation token, or {@code null}. */
+ @Nullable
+ public String getContinuationToken() {
+ return this.continuationToken;
+ }
+
+ /**
+ * Sets the continuation token for retrieving the next page.
+ *
+ * @param continuationToken the token, or {@code null}
+ * @return this query object
+ */
+ public ExportJobQuery setContinuationToken(@Nullable String continuationToken) {
+ this.continuationToken = continuationToken;
+ return this;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQueryResult.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQueryResult.java
new file mode 100644
index 00000000..09560270
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQueryResult.java
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import javax.annotation.Nullable;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Result of {@link ExportHistoryClient#listJobs(ExportJobQuery)}: a page of export job descriptions and a
+ * continuation token for the next page.
+ */
+public final class ExportJobQueryResult {
+
+ private final List jobs;
+ private final String continuationToken;
+
+ /**
+ * Creates an {@code ExportJobQueryResult}.
+ *
+ * @param jobs the page of export job descriptions
+ * @param continuationToken the continuation token for the next page, or {@code null}
+ */
+ public ExportJobQueryResult(List jobs, @Nullable String continuationToken) {
+ this.jobs = Collections.unmodifiableList(jobs);
+ this.continuationToken = continuationToken;
+ }
+
+ /** @return an unmodifiable page of export job descriptions. */
+ public List getJobs() {
+ return this.jobs;
+ }
+
+ /** @return the continuation token for the next page, or {@code null} if there are no more results. */
+ @Nullable
+ public String getContinuationToken() {
+ return this.continuationToken;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java
new file mode 100644
index 00000000..67bdaa83
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java
@@ -0,0 +1,174 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+
+/**
+ * Export job state stored in the {@link ExportJob} entity.
+ */
+final class ExportJobState {
+
+ private ExportJobStatus status;
+ private ExportJobConfiguration config;
+ private ExportCheckpoint checkpoint;
+ private Instant createdAt;
+ private Instant lastModifiedAt;
+ private Instant lastCheckpointTime;
+ private String lastError;
+ private long scannedInstances;
+ private long exportedInstances;
+ private String orchestratorInstanceId;
+
+ /** Creates an empty {@code ExportJobState}. */
+ public ExportJobState() {
+ }
+
+ /** @return the current status of the export job. */
+ public ExportJobStatus getStatus() {
+ return this.status;
+ }
+
+ /**
+ * Sets the current status of the export job.
+ *
+ * @param status the status
+ */
+ public void setStatus(ExportJobStatus status) {
+ this.status = status;
+ }
+
+ /** @return the export job configuration, or {@code null}. */
+ @Nullable
+ public ExportJobConfiguration getConfig() {
+ return this.config;
+ }
+
+ /**
+ * Sets the export job configuration.
+ *
+ * @param config the configuration
+ */
+ public void setConfig(@Nullable ExportJobConfiguration config) {
+ this.config = config;
+ }
+
+ /** @return the resume checkpoint, or {@code null}. */
+ @Nullable
+ public ExportCheckpoint getCheckpoint() {
+ return this.checkpoint;
+ }
+
+ /**
+ * Sets the resume checkpoint.
+ *
+ * @param checkpoint the checkpoint, or {@code null}
+ */
+ public void setCheckpoint(@Nullable ExportCheckpoint checkpoint) {
+ this.checkpoint = checkpoint;
+ }
+
+ /** @return the creation time, or {@code null}. */
+ @Nullable
+ public Instant getCreatedAt() {
+ return this.createdAt;
+ }
+
+ /**
+ * Sets the creation time.
+ *
+ * @param createdAt the creation time
+ */
+ public void setCreatedAt(@Nullable Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ /** @return the last-modified time, or {@code null}. */
+ @Nullable
+ public Instant getLastModifiedAt() {
+ return this.lastModifiedAt;
+ }
+
+ /**
+ * Sets the last-modified time.
+ *
+ * @param lastModifiedAt the last-modified time
+ */
+ public void setLastModifiedAt(@Nullable Instant lastModifiedAt) {
+ this.lastModifiedAt = lastModifiedAt;
+ }
+
+ /** @return the time of the last checkpoint, or {@code null}. */
+ @Nullable
+ public Instant getLastCheckpointTime() {
+ return this.lastCheckpointTime;
+ }
+
+ /**
+ * Sets the time of the last checkpoint.
+ *
+ * @param lastCheckpointTime the last checkpoint time
+ */
+ public void setLastCheckpointTime(@Nullable Instant lastCheckpointTime) {
+ this.lastCheckpointTime = lastCheckpointTime;
+ }
+
+ /** @return the last error message, or {@code null}. */
+ @Nullable
+ public String getLastError() {
+ return this.lastError;
+ }
+
+ /**
+ * Sets the last error message.
+ *
+ * @param lastError the error message, or {@code null}
+ */
+ public void setLastError(@Nullable String lastError) {
+ this.lastError = lastError;
+ }
+
+ /** @return the total number of instances scanned. */
+ public long getScannedInstances() {
+ return this.scannedInstances;
+ }
+
+ /**
+ * Sets the total number of instances scanned.
+ *
+ * @param scannedInstances the scanned count
+ */
+ public void setScannedInstances(long scannedInstances) {
+ this.scannedInstances = scannedInstances;
+ }
+
+ /** @return the total number of instances exported. */
+ public long getExportedInstances() {
+ return this.exportedInstances;
+ }
+
+ /**
+ * Sets the total number of instances exported.
+ *
+ * @param exportedInstances the exported count
+ */
+ public void setExportedInstances(long exportedInstances) {
+ this.exportedInstances = exportedInstances;
+ }
+
+ /** @return the instance ID of the orchestrator running this job, or {@code null}. */
+ @Nullable
+ public String getOrchestratorInstanceId() {
+ return this.orchestratorInstanceId;
+ }
+
+ /**
+ * Sets the instance ID of the orchestrator running this job.
+ *
+ * @param orchestratorInstanceId the orchestrator instance ID, or {@code null}
+ */
+ public void setOrchestratorInstanceId(@Nullable String orchestratorInstanceId) {
+ this.orchestratorInstanceId = orchestratorInstanceId;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobStatus.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobStatus.java
new file mode 100644
index 00000000..7931c2ba
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobStatus.java
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+/**
+ * Represents the current status of an export history job.
+ */
+public enum ExportJobStatus {
+ /** The export history job has been created but is not yet active. */
+ PENDING,
+
+ /** The export history job is active and running. */
+ ACTIVE,
+
+ /** The export history job failed. */
+ FAILED,
+
+ /** The export history job completed. */
+ COMPLETED
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java
new file mode 100644
index 00000000..9a71d79d
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+/**
+ * Valid state-transition rules for export jobs. The operation-name constants match the {@link ExportJob} entity
+ * method names (case-insensitive dispatch).
+ */
+final class ExportJobTransitions {
+
+ /** The {@code Create} operation name. */
+ public static final String OP_CREATE = "Create";
+
+ /** The {@code Get} operation name. */
+ public static final String OP_GET = "Get";
+
+ /** The {@code Run} operation name. */
+ public static final String OP_RUN = "Run";
+
+ /** The {@code CommitCheckpoint} operation name. */
+ public static final String OP_COMMIT_CHECKPOINT = "CommitCheckpoint";
+
+ /** The {@code MarkAsCompleted} operation name. */
+ public static final String OP_MARK_AS_COMPLETED = "MarkAsCompleted";
+
+ /** The {@code MarkAsFailed} operation name. */
+ public static final String OP_MARK_AS_FAILED = "MarkAsFailed";
+
+ /** The {@code Delete} operation name. */
+ public static final String OP_DELETE = "Delete";
+
+ private ExportJobTransitions() {
+ }
+
+ /**
+ * Checks whether a transition to the target state is valid for the given operation and current state.
+ *
+ * @param operationName the name of the operation being performed
+ * @param from the current export job status
+ * @param targetState the target status to transition to
+ * @return {@code true} if the transition is valid; otherwise {@code false}
+ */
+ public static boolean isValidTransition(String operationName, ExportJobStatus from, ExportJobStatus targetState) {
+ if (operationName == null) {
+ return false;
+ }
+ switch (operationName) {
+ case OP_CREATE:
+ return targetState == ExportJobStatus.ACTIVE
+ && (from == ExportJobStatus.PENDING
+ || from == ExportJobStatus.FAILED
+ || from == ExportJobStatus.COMPLETED);
+ case OP_MARK_AS_COMPLETED:
+ return from == ExportJobStatus.ACTIVE && targetState == ExportJobStatus.COMPLETED;
+ case OP_MARK_AS_FAILED:
+ return from == ExportJobStatus.ACTIVE && targetState == ExportJobStatus.FAILED;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportMode.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportMode.java
new file mode 100644
index 00000000..c96399c0
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportMode.java
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+/**
+ * Export job modes.
+ */
+public enum ExportMode {
+ /** Exports a fixed completion-time window and then completes. */
+ BATCH,
+
+ /** Tails terminal instances continuously until the job is stopped. */
+ CONTINUOUS
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java
new file mode 100644
index 00000000..e2bc83ed
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java
@@ -0,0 +1,428 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.microsoft.durabletask.FailureDetails;
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+import com.microsoft.durabletask.history.ContinueAsNewEvent;
+import com.microsoft.durabletask.history.EntityLockGrantedEvent;
+import com.microsoft.durabletask.history.EntityLockRequestedEvent;
+import com.microsoft.durabletask.history.EntityOperationCalledEvent;
+import com.microsoft.durabletask.history.EntityOperationCompletedEvent;
+import com.microsoft.durabletask.history.EntityOperationFailedEvent;
+import com.microsoft.durabletask.history.EntityOperationSignaledEvent;
+import com.microsoft.durabletask.history.EntityUnlockSentEvent;
+import com.microsoft.durabletask.history.EventRaisedEvent;
+import com.microsoft.durabletask.history.EventSentEvent;
+import com.microsoft.durabletask.history.ExecutionCompletedEvent;
+import com.microsoft.durabletask.history.ExecutionResumedEvent;
+import com.microsoft.durabletask.history.ExecutionStartedEvent;
+import com.microsoft.durabletask.history.ExecutionSuspendedEvent;
+import com.microsoft.durabletask.history.ExecutionTerminatedEvent;
+import com.microsoft.durabletask.history.GenericEvent;
+import com.microsoft.durabletask.history.HistoryEvent;
+import com.microsoft.durabletask.history.HistoryStateEvent;
+import com.microsoft.durabletask.history.OrchestrationInstance;
+import com.microsoft.durabletask.history.OrchestrationState;
+import com.microsoft.durabletask.history.ParentInstanceInfo;
+import com.microsoft.durabletask.history.SubOrchestrationInstanceCompletedEvent;
+import com.microsoft.durabletask.history.SubOrchestrationInstanceCreatedEvent;
+import com.microsoft.durabletask.history.SubOrchestrationInstanceFailedEvent;
+import com.microsoft.durabletask.history.TaskCompletedEvent;
+import com.microsoft.durabletask.history.TaskFailedEvent;
+import com.microsoft.durabletask.history.TaskScheduledEvent;
+import com.microsoft.durabletask.history.TimerCreatedEvent;
+import com.microsoft.durabletask.history.TimerFiredEvent;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UncheckedIOException;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Serializes the {@code com.microsoft.durabletask.history} domain model to the export wire format.
+ *
+ * Each event is written as a single JSON object with a leading {@code eventType} discriminator, the type-specific
+ * fields, and a trailing {@code eventId}/{@code isPlayed}/{@code timestamp}: camelCase field names, null fields
+ * omitted, empty maps rendered as {@code {}}, enum values in PascalCase, timestamps as trimmed ISO-8601 with a
+ * {@code Z} suffix, and (for non-entity events) strings escaped by {@link HtmlSafeJsonEscapes}.
+ *
+ * {@link ExportFormatKind#JSONL} emits one object per line (gzip applied by the blob writer);
+ * {@link ExportFormatKind#JSON} emits a single JSON array.
+ *
+ * Entity events have no dedicated representation in this wire format, so they fall back to a Java-native shape: a
+ * reflective projection of the event with an added {@code eventType} discriminator (serialized with Jackson defaults).
+ */
+final class HistoryEventSerializer {
+
+ private static final DateTimeFormatter DATE_TIME =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
+
+ private static final JsonFactory FACTORY = new JsonFactory();
+
+ // Fallback for entity events, which have no dedicated wire-format representation.
+ private static final ObjectMapper LEGACY_MAPPER = JsonMapper.builder()
+ .findAndAddModules()
+ .propertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE)
+ .serializationInclusion(JsonInclude.Include.NON_NULL)
+ .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+ .build();
+
+ private HistoryEventSerializer() {
+ }
+
+ /**
+ * Serializes the history events into the format requested by {@code format}.
+ *
+ * @param historyEvents the ordered history events
+ * @param format the export format
+ * @return the serialized content (JSONL text or JSON array text)
+ * @throws JsonProcessingException if serialization of an entity event fails
+ */
+ static String serialize(List historyEvents, ExportFormat format)
+ throws JsonProcessingException {
+ StringBuilder sb = new StringBuilder();
+ boolean json = format.getKind() == ExportFormatKind.JSON;
+ if (json) {
+ sb.append('[');
+ }
+ for (int i = 0; i < historyEvents.size(); i++) {
+ HistoryEvent event = historyEvents.get(i);
+ String line = isEntityEvent(event)
+ ? writeEntity(event)
+ : writeObject(coreMap(event));
+ if (json) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append(line);
+ } else {
+ sb.append(line).append('\n');
+ }
+ }
+ if (json) {
+ sb.append(']');
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Gets the blob file extension for a format.
+ *
+ * @param format the export format
+ * @return {@code "jsonl.gz"} for JSONL (compressed) or {@code "json"} for JSON
+ */
+ static String fileExtension(ExportFormat format) {
+ return format.getKind() == ExportFormatKind.JSON ? "json" : "jsonl.gz";
+ }
+
+ /**
+ * Gets whether the format's content is gzip-compressed.
+ *
+ * @param format the export format
+ * @return {@code true} for JSONL, {@code false} for JSON
+ */
+ static boolean isCompressed(ExportFormat format) {
+ return format.getKind() != ExportFormatKind.JSON;
+ }
+
+ /**
+ * Gets the blob content type for a format.
+ *
+ * @param format the export format
+ * @return the content type
+ */
+ static String contentType(ExportFormat format) {
+ return format.getKind() == ExportFormatKind.JSON ? "application/json" : "application/jsonl+gzip";
+ }
+
+ // ---- event -> ordered map ------------------------------------------------------------------
+
+ private static Map coreMap(HistoryEvent event) {
+ LinkedHashMap m = new LinkedHashMap<>();
+ m.put("eventType", eventType(event));
+
+ if (event instanceof ExecutionStartedEvent) {
+ ExecutionStartedEvent e = (ExecutionStartedEvent) event;
+ putIfNotNull(m, "parentInstance", parentInstanceMap(e.getParentInstance()));
+ putIfNotNull(m, "name", e.getName());
+ putIfNotNull(m, "version", e.getVersion());
+ putIfNotNull(m, "input", e.getInput());
+ m.put("tags", tagsMap(e.getTags()));
+ putIfNotNull(m, "scheduledStartTime", formatInstantOrNull(e.getScheduledStartTimestamp()));
+ putIfNotNull(m, "orchestrationInstance", instanceMap(e.getOrchestrationInstance()));
+ } else if (event instanceof ExecutionCompletedEvent) {
+ ExecutionCompletedEvent e = (ExecutionCompletedEvent) event;
+ m.put("orchestrationStatus", statusString(e.getOrchestrationStatus()));
+ putIfNotNull(m, "result", e.getResult());
+ putIfNotNull(m, "failureDetails", failureMap(e.getFailureDetails()));
+ } else if (event instanceof ContinueAsNewEvent) {
+ // The export format models continue-as-new as an ExecutionCompleted with ContinuedAsNew status.
+ ContinueAsNewEvent e = (ContinueAsNewEvent) event;
+ m.put("orchestrationStatus", "ContinuedAsNew");
+ putIfNotNull(m, "result", e.getInput());
+ } else if (event instanceof ExecutionTerminatedEvent) {
+ putIfNotNull(m, "input", ((ExecutionTerminatedEvent) event).getInput());
+ } else if (event instanceof ExecutionSuspendedEvent) {
+ putIfNotNull(m, "reason", ((ExecutionSuspendedEvent) event).getInput());
+ } else if (event instanceof ExecutionResumedEvent) {
+ putIfNotNull(m, "reason", ((ExecutionResumedEvent) event).getInput());
+ } else if (event instanceof TaskScheduledEvent) {
+ TaskScheduledEvent e = (TaskScheduledEvent) event;
+ putIfNotNull(m, "name", e.getName());
+ putIfNotNull(m, "version", e.getVersion());
+ putIfNotNull(m, "input", e.getInput());
+ m.put("tags", tagsMap(e.getTags()));
+ } else if (event instanceof TaskCompletedEvent) {
+ TaskCompletedEvent e = (TaskCompletedEvent) event;
+ m.put("taskScheduledId", e.getTaskScheduledId());
+ putIfNotNull(m, "result", e.getResult());
+ } else if (event instanceof TaskFailedEvent) {
+ TaskFailedEvent e = (TaskFailedEvent) event;
+ m.put("taskScheduledId", e.getTaskScheduledId());
+ putIfNotNull(m, "failureDetails", failureMap(e.getFailureDetails()));
+ } else if (event instanceof SubOrchestrationInstanceCreatedEvent) {
+ SubOrchestrationInstanceCreatedEvent e = (SubOrchestrationInstanceCreatedEvent) event;
+ putIfNotNull(m, "name", e.getName());
+ putIfNotNull(m, "version", e.getVersion());
+ putIfNotNull(m, "instanceId", e.getInstanceId());
+ putIfNotNull(m, "input", e.getInput());
+ } else if (event instanceof SubOrchestrationInstanceCompletedEvent) {
+ SubOrchestrationInstanceCompletedEvent e = (SubOrchestrationInstanceCompletedEvent) event;
+ m.put("taskScheduledId", e.getTaskScheduledId());
+ putIfNotNull(m, "result", e.getResult());
+ } else if (event instanceof SubOrchestrationInstanceFailedEvent) {
+ SubOrchestrationInstanceFailedEvent e = (SubOrchestrationInstanceFailedEvent) event;
+ m.put("taskScheduledId", e.getTaskScheduledId());
+ putIfNotNull(m, "failureDetails", failureMap(e.getFailureDetails()));
+ } else if (event instanceof TimerCreatedEvent) {
+ m.put("fireAt", formatInstant(((TimerCreatedEvent) event).getFireAt()));
+ } else if (event instanceof TimerFiredEvent) {
+ TimerFiredEvent e = (TimerFiredEvent) event;
+ m.put("timerId", e.getTimerId());
+ m.put("fireAt", formatInstant(e.getFireAt()));
+ } else if (event instanceof EventSentEvent) {
+ EventSentEvent e = (EventSentEvent) event;
+ putIfNotNull(m, "instanceId", e.getInstanceId());
+ putIfNotNull(m, "name", e.getName());
+ putIfNotNull(m, "input", e.getInput());
+ } else if (event instanceof EventRaisedEvent) {
+ EventRaisedEvent e = (EventRaisedEvent) event;
+ putIfNotNull(m, "name", e.getName());
+ putIfNotNull(m, "input", e.getInput());
+ } else if (event instanceof GenericEvent) {
+ putIfNotNull(m, "data", ((GenericEvent) event).getData());
+ } else if (event instanceof HistoryStateEvent) {
+ putIfNotNull(m, "state", stateMap(((HistoryStateEvent) event).getState()));
+ }
+ // OrchestratorStartedEvent, OrchestratorCompletedEvent, ExecutionRewoundEvent carry no extra fields.
+
+ // TimerFired carries eventId -1 in the export format.
+ m.put("eventId", (event instanceof TimerFiredEvent) ? -1 : event.getEventId());
+ m.put("isPlayed", Boolean.FALSE);
+ m.put("timestamp", formatInstant(event.getTimestamp()));
+ return m;
+ }
+
+ private static Map instanceMap(OrchestrationInstance oi) {
+ if (oi == null) {
+ return null;
+ }
+ LinkedHashMap m = new LinkedHashMap<>();
+ putIfNotNull(m, "instanceId", oi.getInstanceId());
+ putIfNotNull(m, "executionId", oi.getExecutionId());
+ return m;
+ }
+
+ private static Map parentInstanceMap(ParentInstanceInfo p) {
+ if (p == null) {
+ return null;
+ }
+ LinkedHashMap m = new LinkedHashMap<>();
+ putIfNotNull(m, "name", p.getName());
+ putIfNotNull(m, "orchestrationInstance", instanceMap(p.getOrchestrationInstance()));
+ m.put("taskScheduleId", p.getTaskScheduledId());
+ putIfNotNull(m, "version", p.getVersion());
+ return m;
+ }
+
+ private static Map failureMap(FailureDetails f) {
+ if (f == null) {
+ return null;
+ }
+ LinkedHashMap m = new LinkedHashMap<>();
+ m.put("errorType", f.getErrorType());
+ putIfNotNull(m, "errorMessage", f.getErrorMessage());
+ putIfNotNull(m, "stackTrace", f.getStackTrace());
+ putIfNotNull(m, "innerFailure", failureMap(f.getInnerFailure()));
+ m.put("isNonRetriable", f.isNonRetriable());
+ return m;
+ }
+
+ private static Map stateMap(OrchestrationState s) {
+ if (s == null) {
+ return null;
+ }
+ LinkedHashMap m = new LinkedHashMap<>();
+ putIfNotNull(m, "scheduledStartTime", formatInstantOrNull(s.getScheduledStartTime()));
+ // The export format leaves these at their defaults (they are not populated during history retrieval).
+ m.put("completedTime", "0001-01-01T00:00:00");
+ m.put("compressedSize", 0);
+ putIfNotNull(m, "createdTime", formatInstantOrNull(s.getCreatedTime()));
+ putIfNotNull(m, "input", s.getInput());
+ putIfNotNull(m, "lastUpdatedTime", formatInstantOrNull(s.getLastUpdatedTime()));
+ putIfNotNull(m, "name", s.getName());
+ LinkedHashMap oi = new LinkedHashMap<>();
+ putIfNotNull(oi, "instanceId", s.getInstanceId());
+ m.put("orchestrationInstance", oi);
+ m.put("orchestrationStatus", "Running");
+ putIfNotNull(m, "output", s.getOutput());
+ m.put("size", 0);
+ putIfNotNull(m, "status", s.getCustomStatus());
+ m.put("tags", tagsMap(s.getTags()));
+ putIfNotNull(m, "version", s.getVersion());
+ return m;
+ }
+
+ private static Map tagsMap(Map tags) {
+ LinkedHashMap m = new LinkedHashMap<>();
+ if (tags != null) {
+ m.putAll(tags);
+ }
+ return m;
+ }
+
+ private static String statusString(OrchestrationRuntimeStatus status) {
+ switch (status) {
+ case RUNNING: return "Running";
+ case COMPLETED: return "Completed";
+ case CONTINUED_AS_NEW: return "ContinuedAsNew";
+ case FAILED: return "Failed";
+ case CANCELED: return "Canceled";
+ case TERMINATED: return "Terminated";
+ case PENDING: return "Pending";
+ case SUSPENDED: return "Suspended";
+ default: return status.name();
+ }
+ }
+
+ private static String eventType(HistoryEvent event) {
+ // GenericEvent keeps its suffix in the export format; every other event drops the trailing "Event".
+ if (event instanceof GenericEvent) {
+ return "GenericEvent";
+ }
+ String name = event.getClass().getSimpleName();
+ return name.endsWith("Event") ? name.substring(0, name.length() - "Event".length()) : name;
+ }
+
+ private static boolean isEntityEvent(HistoryEvent event) {
+ return event instanceof EntityOperationCalledEvent
+ || event instanceof EntityOperationSignaledEvent
+ || event instanceof EntityOperationCompletedEvent
+ || event instanceof EntityOperationFailedEvent
+ || event instanceof EntityLockRequestedEvent
+ || event instanceof EntityLockGrantedEvent
+ || event instanceof EntityUnlockSentEvent;
+ }
+
+ private static void putIfNotNull(Map m, String key, Object value) {
+ if (value != null) {
+ m.put(key, value);
+ }
+ }
+
+ private static String formatInstantOrNull(Instant t) {
+ return t == null ? null : formatInstant(t);
+ }
+
+ private static String formatInstant(Instant t) {
+ OffsetDateTime utc = t.atOffset(ZoneOffset.UTC);
+ StringBuilder sb = new StringBuilder(DATE_TIME.format(utc));
+ int nanos = utc.getNano();
+ if (nanos != 0) {
+ // Up to seven fractional digits (100-ns ticks), trailing zeros trimmed.
+ String frac = String.format(Locale.ROOT, "%09d", nanos).substring(0, 7);
+ int end = frac.length();
+ while (end > 0 && frac.charAt(end - 1) == '0') {
+ end--;
+ }
+ if (end > 0) {
+ sb.append('.').append(frac, 0, end);
+ }
+ }
+ sb.append('Z');
+ return sb.toString();
+ }
+
+ // ---- ordered map -> JSON with the parity escaper -------------------------------------------
+
+ private static String writeEntity(HistoryEvent event) throws JsonProcessingException {
+ // Entity events have no wire-format equivalent; project the event reflectively and prepend an eventType.
+ ObjectNode node = LEGACY_MAPPER.valueToTree(event);
+ ObjectNode withType = LEGACY_MAPPER.createObjectNode();
+ withType.put("eventType", eventType(event));
+ withType.setAll(node);
+ return LEGACY_MAPPER.writeValueAsString(withType);
+ }
+
+ private static String writeObject(Map map) {
+ StringWriter sw = new StringWriter();
+ try (JsonGenerator g = FACTORY.createGenerator(sw)) {
+ g.setCharacterEscapes(new HtmlSafeJsonEscapes());
+ g.setHighestNonEscapedChar(0x7F);
+ writeMap(g, map);
+ } catch (IOException ex) {
+ throw new UncheckedIOException(ex);
+ }
+ return sw.toString();
+ }
+
+ private static void writeMap(JsonGenerator g, Map, ?> map) throws IOException {
+ g.writeStartObject();
+ for (Map.Entry, ?> e : map.entrySet()) {
+ g.writeFieldName(String.valueOf(e.getKey()));
+ writeValue(g, e.getValue());
+ }
+ g.writeEndObject();
+ }
+
+ private static void writeValue(JsonGenerator g, Object v) throws IOException {
+ if (v == null) {
+ g.writeNull();
+ } else if (v instanceof String) {
+ g.writeString((String) v);
+ } else if (v instanceof Integer) {
+ g.writeNumber((Integer) v);
+ } else if (v instanceof Long) {
+ g.writeNumber((Long) v);
+ } else if (v instanceof Boolean) {
+ g.writeBoolean((Boolean) v);
+ } else if (v instanceof Map) {
+ writeMap(g, (Map, ?>) v);
+ } else if (v instanceof Iterable) {
+ g.writeStartArray();
+ for (Object o : (Iterable>) v) {
+ writeValue(g, o);
+ }
+ g.writeEndArray();
+ } else {
+ g.writeString(v.toString());
+ }
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HtmlSafeJsonEscapes.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HtmlSafeJsonEscapes.java
new file mode 100644
index 00000000..f4d25bf2
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HtmlSafeJsonEscapes.java
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.fasterxml.jackson.core.SerializableString;
+import com.fasterxml.jackson.core.io.CharacterEscapes;
+import com.fasterxml.jackson.core.io.SerializedString;
+
+/**
+ * Character escaping that reproduces the export wire format's HTML-safe encoder byte-for-byte.
+ *
+ * Escaping rules (paired with {@code JsonGenerator.setHighestNonEscapedChar(0x7F)} so every character at or above
+ * {@code U+0080} is escaped):
+ *
+ * - {@code " & ' + < > `} and {@code DEL (0x7F)} are written as {@code \\uXXXX} (uppercase hex), not the JSON
+ * short forms.
+ * - {@code \\ \b \t \n \f \r} keep their JSON short forms.
+ * - Other control characters below {@code 0x20} are written as {@code \\uXXXX}.
+ * - Every character at or above {@code U+0080} (including surrogate-pair halves) is written as {@code \\uXXXX}
+ * (uppercase hex).
+ *
+ */
+final class HtmlSafeJsonEscapes extends CharacterEscapes {
+
+ private static final long serialVersionUID = 1L;
+
+ /** Characters that must be emitted as {@code \\uXXXX} instead of their JSON short escape. */
+ private static final int[] FORCED_UNICODE = {'"', '&', '\'', '+', '<', '>', '`', 0x7F};
+
+ private final int[] asciiEscapes;
+
+ HtmlSafeJsonEscapes() {
+ int[] esc = CharacterEscapes.standardAsciiEscapesForJSON();
+ for (int c : FORCED_UNICODE) {
+ esc[c] = CharacterEscapes.ESCAPE_CUSTOM;
+ }
+ this.asciiEscapes = esc;
+ }
+
+ @Override
+ public int[] getEscapeCodesForAscii() {
+ return this.asciiEscapes;
+ }
+
+ @Override
+ public SerializableString getEscapeSequence(int ch) {
+ return new SerializedString(String.format("\\u%04X", ch));
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java
new file mode 100644
index 00000000..51d7c646
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java
@@ -0,0 +1,231 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.DurableTaskClient;
+import com.microsoft.durabletask.ListInstanceIdsQuery;
+import com.microsoft.durabletask.ListInstanceIdsResult;
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+import com.microsoft.durabletask.TaskActivity;
+import com.microsoft.durabletask.TaskActivityContext;
+
+import javax.annotation.Nullable;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Activity that lists terminal orchestration instances for a completion-time window using the client
+ * {@code listInstanceIds} wrapper, returning a page plus the checkpoint to advance to.
+ */
+final class ListTerminalInstancesActivity implements TaskActivity {
+
+ /** The registered activity name. */
+ public static final String NAME = "ListTerminalInstancesActivity";
+
+ private final DurableTaskClient client;
+
+ /**
+ * Creates a {@code ListTerminalInstancesActivity}.
+ *
+ * @param client the Durable Task client used to list instances
+ */
+ public ListTerminalInstancesActivity(DurableTaskClient client) {
+ this.client = client;
+ }
+
+ @Override
+ public Object run(TaskActivityContext ctx) {
+ ListTerminalInstancesRequest input = ctx.getInput(ListTerminalInstancesRequest.class);
+
+ ListInstanceIdsQuery query = new ListInstanceIdsQuery()
+ .setCompletedTimeFrom(input.getCompletedTimeFrom())
+ .setCompletedTimeTo(input.getCompletedTimeTo())
+ .setRuntimeStatusList(input.getRuntimeStatus())
+ .setPageSize(input.getMaxInstancesPerBatch())
+ .setContinuationToken(input.getLastInstanceKey());
+
+ ListInstanceIdsResult result = this.client.listInstanceIds(query);
+ List instanceIds = new ArrayList<>(result.getInstanceIds());
+
+ // The continuation token is an instance-key cursor. When the service returns null on a
+ // non-empty page, advance the checkpoint to the last instance ID so the next request resumes
+ // after it and can return an empty page to terminate paging. Committing a null checkpoint here
+ // would restart paging from the beginning (BATCH jobs would never complete, causing duplicate
+ // exports).
+ String nextKey = result.getContinuationToken();
+ if (nextKey == null && !instanceIds.isEmpty()) {
+ nextKey = instanceIds.get(instanceIds.size() - 1);
+ }
+
+ return new InstancePage(
+ instanceIds,
+ nextKey == null ? null : new ExportCheckpoint(nextKey));
+ }
+}
+
+/**
+ * Input to {@link ListTerminalInstancesActivity}: a completion-time window, terminal status filter, pagination
+ * cursor, and batch size.
+ */
+final class ListTerminalInstancesRequest {
+
+ private Instant completedTimeFrom;
+ private Instant completedTimeTo;
+ private List runtimeStatus;
+ private String lastInstanceKey;
+ private int maxInstancesPerBatch;
+
+ /** Creates an empty {@code ListTerminalInstancesRequest} (for deserialization). */
+ public ListTerminalInstancesRequest() {
+ }
+
+ /**
+ * Creates a {@code ListTerminalInstancesRequest}.
+ *
+ * @param completedTimeFrom the inclusive completion-time lower bound
+ * @param completedTimeTo the inclusive completion-time upper bound, or {@code null}
+ * @param runtimeStatus the terminal runtime statuses, or {@code null}
+ * @param lastInstanceKey the pagination cursor from the previous page, or {@code null}
+ * @param maxInstancesPerBatch the maximum number of instance IDs per page
+ */
+ public ListTerminalInstancesRequest(
+ Instant completedTimeFrom,
+ @Nullable Instant completedTimeTo,
+ @Nullable List runtimeStatus,
+ @Nullable String lastInstanceKey,
+ int maxInstancesPerBatch) {
+ this.completedTimeFrom = completedTimeFrom;
+ this.completedTimeTo = completedTimeTo;
+ this.runtimeStatus = runtimeStatus;
+ this.lastInstanceKey = lastInstanceKey;
+ this.maxInstancesPerBatch = maxInstancesPerBatch;
+ }
+
+ /** @return the inclusive completion-time lower bound. */
+ public Instant getCompletedTimeFrom() {
+ return this.completedTimeFrom;
+ }
+
+ /**
+ * Sets the inclusive completion-time lower bound.
+ *
+ * @param completedTimeFrom the lower bound
+ */
+ public void setCompletedTimeFrom(Instant completedTimeFrom) {
+ this.completedTimeFrom = completedTimeFrom;
+ }
+
+ /** @return the inclusive completion-time upper bound, or {@code null}. */
+ @Nullable
+ public Instant getCompletedTimeTo() {
+ return this.completedTimeTo;
+ }
+
+ /**
+ * Sets the inclusive completion-time upper bound.
+ *
+ * @param completedTimeTo the upper bound, or {@code null}
+ */
+ public void setCompletedTimeTo(@Nullable Instant completedTimeTo) {
+ this.completedTimeTo = completedTimeTo;
+ }
+
+ /** @return the terminal runtime statuses, or {@code null}. */
+ @Nullable
+ public List getRuntimeStatus() {
+ return this.runtimeStatus;
+ }
+
+ /**
+ * Sets the terminal runtime statuses.
+ *
+ * @param runtimeStatus the runtime statuses, or {@code null}
+ */
+ public void setRuntimeStatus(@Nullable List runtimeStatus) {
+ this.runtimeStatus = runtimeStatus;
+ }
+
+ /** @return the pagination cursor from the previous page, or {@code null}. */
+ @Nullable
+ public String getLastInstanceKey() {
+ return this.lastInstanceKey;
+ }
+
+ /**
+ * Sets the pagination cursor.
+ *
+ * @param lastInstanceKey the cursor, or {@code null}
+ */
+ public void setLastInstanceKey(@Nullable String lastInstanceKey) {
+ this.lastInstanceKey = lastInstanceKey;
+ }
+
+ /** @return the maximum number of instance IDs per page. */
+ public int getMaxInstancesPerBatch() {
+ return this.maxInstancesPerBatch;
+ }
+
+ /**
+ * Sets the maximum number of instance IDs per page.
+ *
+ * @param maxInstancesPerBatch the batch size
+ */
+ public void setMaxInstancesPerBatch(int maxInstancesPerBatch) {
+ this.maxInstancesPerBatch = maxInstancesPerBatch;
+ }
+}
+
+/**
+ * Output of {@link ListTerminalInstancesActivity}: a page of terminal instance IDs plus the checkpoint to advance
+ * to once this page has been exported.
+ */
+final class InstancePage {
+
+ private List instanceIds = new ArrayList<>();
+ private ExportCheckpoint nextCheckpoint;
+
+ /** Creates an empty {@code InstancePage} (for deserialization). */
+ public InstancePage() {
+ }
+
+ /**
+ * Creates an {@code InstancePage}.
+ *
+ * @param instanceIds the page of terminal instance IDs
+ * @param nextCheckpoint the checkpoint to advance to after exporting this page, or {@code null}
+ */
+ public InstancePage(List instanceIds, @Nullable ExportCheckpoint nextCheckpoint) {
+ this.instanceIds = instanceIds;
+ this.nextCheckpoint = nextCheckpoint;
+ }
+
+ /** @return the page of terminal instance IDs. */
+ public List getInstanceIds() {
+ return this.instanceIds;
+ }
+
+ /**
+ * Sets the page of terminal instance IDs.
+ *
+ * @param instanceIds the instance IDs
+ */
+ public void setInstanceIds(List instanceIds) {
+ this.instanceIds = instanceIds;
+ }
+
+ /** @return the checkpoint to advance to after exporting this page, or {@code null}. */
+ @Nullable
+ public ExportCheckpoint getNextCheckpoint() {
+ return this.nextCheckpoint;
+ }
+
+ /**
+ * Sets the checkpoint to advance to after exporting this page.
+ *
+ * @param nextCheckpoint the next checkpoint, or {@code null}
+ */
+ public void setNextCheckpoint(@Nullable ExportCheckpoint nextCheckpoint) {
+ this.nextCheckpoint = nextCheckpoint;
+ }
+}
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/package-info.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/package-info.java
new file mode 100644
index 00000000..eaa1900d
--- /dev/null
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/package-info.java
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+/**
+ * Durable export of terminal orchestration history to Azure Blob Storage for the Durable Task Java SDK.
+ *
+ * A durable, checkpointed entity + orchestrator pages terminal instances by completion window, fans out
+ * per-instance export activities, and uploads serialized history (gzipped JSONL by default) to a customer-owned
+ * blob container.
+ *
+ *
Components
+ *
+ * - {@link com.microsoft.durabletask.exporthistory.ExportJob} entity — config, status, checkpoint cursor, and
+ * progress counters; signals a run on create.
+ * - {@link com.microsoft.durabletask.exporthistory.ExportJobOrchestrator} — pages terminal instances, fans out
+ * export activities, commits checkpoints, handles BATCH vs CONTINUOUS, retries with backoff, and
+ * {@code continueAsNew}s periodically.
+ * - {@link com.microsoft.durabletask.exporthistory.ListTerminalInstancesActivity} — calls the client
+ * {@code listInstanceIds} wrapper.
+ * - {@link com.microsoft.durabletask.exporthistory.ExportInstanceHistoryActivity} — calls the client
+ * {@code getOrchestrationHistory} wrapper, serializes the {@code com.microsoft.durabletask.history} domain
+ * model to gzipped JSONL, and uploads to blob.
+ * - {@link com.microsoft.durabletask.exporthistory.ExportHistoryClient} /
+ * {@link com.microsoft.durabletask.exporthistory.ExportHistoryJobClient} — {@code createJob}/{@code getJob}/
+ * {@code listJobs}/{@code getJobClient}, backed by entity operations and reads.
+ * - {@link com.microsoft.durabletask.exporthistory.ExportHistoryWorkerExtensions} and
+ * {@link com.microsoft.durabletask.exporthistory.ExportHistoryClientExtensions} — worker/client registration
+ * entry points.
+ *
+ *
+ * @see com.microsoft.durabletask.history
+ */
+package com.microsoft.durabletask.exporthistory;
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java
new file mode 100644
index 00000000..1c3c805d
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java
@@ -0,0 +1,95 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link ExportBlobNaming}.
+ */
+class ExportBlobNamingTest {
+
+ private static final Instant TS = Instant.parse("2026-06-30T12:00:00Z");
+ private static final ExportFormat JSONL = new ExportFormat(ExportFormatKind.JSONL, "1.0");
+ private static final ExportFormat JSON = new ExportFormat(ExportFormatKind.JSON, "1.0");
+
+ @Test
+ void blobFileName_isDeterministic() {
+ String a = ExportBlobNaming.blobFileName(TS, "inst-1", JSONL);
+ String b = ExportBlobNaming.blobFileName(TS, "inst-1", JSONL);
+ assertEquals(a, b);
+ }
+
+ @Test
+ void blobFileName_differsByInstance() {
+ assertTrue(!ExportBlobNaming.blobFileName(TS, "inst-1", JSONL)
+ .equals(ExportBlobNaming.blobFileName(TS, "inst-2", JSONL)));
+ }
+
+ @Test
+ void blobFileName_hasFormatExtension() {
+ assertTrue(ExportBlobNaming.blobFileName(TS, "inst-1", JSONL).endsWith(".jsonl.gz"));
+ assertTrue(ExportBlobNaming.blobFileName(TS, "inst-1", JSON).endsWith(".json"));
+ }
+
+ @Test
+ void blobFileName_hashIs64HexChars() {
+ String name = ExportBlobNaming.blobFileName(TS, "inst-1", JSONL);
+ String hash = name.substring(0, name.indexOf('.'));
+ assertEquals(64, hash.length());
+ assertTrue(hash.matches("[0-9a-f]{64}"));
+ }
+
+ @Test
+ void blobPath_handlesPrefix() {
+ assertEquals("file", ExportBlobNaming.blobPath(null, "file"));
+ assertEquals("file", ExportBlobNaming.blobPath("", "file"));
+ assertEquals("exports/file", ExportBlobNaming.blobPath("exports", "file"));
+ assertEquals("exports/file", ExportBlobNaming.blobPath("exports/", "file"));
+ assertEquals("exports/file", ExportBlobNaming.blobPath("exports///", "file"));
+ }
+
+ @Test
+ void formatTimestamp_hasSevenFractionalDigitsAndUtcOffset() {
+ assertEquals("2026-06-30T12:00:00.0000000+00:00", ExportBlobNaming.formatTimestamp(TS));
+ assertEquals("2026-06-30T12:00:00.1230000+00:00",
+ ExportBlobNaming.formatTimestamp(Instant.parse("2026-06-30T12:00:00.123Z")));
+ }
+
+ @Test
+ void formatTimestamp_isLocaleInvariant() {
+ Locale original = Locale.getDefault(Locale.Category.FORMAT);
+ try {
+ Locale.setDefault(Locale.Category.FORMAT, Locale.forLanguageTag("ar-EG"));
+ assertEquals("2026-06-30T12:00:00.1230000+00:00",
+ ExportBlobNaming.formatTimestamp(Instant.parse("2026-06-30T12:00:00.123Z")));
+ } finally {
+ Locale.setDefault(Locale.Category.FORMAT, original);
+ }
+ }
+
+ @Test
+ void blobFileName_isSha256OfTimestampAndInstanceId() throws Exception {
+ // Blob name = lowercase-hex SHA-256 of "|" + extension.
+ String expected = sha256Hex("2026-06-30T12:00:00.0000000+00:00|inst-1") + ".jsonl.gz";
+ assertEquals(expected, ExportBlobNaming.blobFileName(TS, "inst-1", JSONL));
+ }
+
+ private static String sha256Hex(String value) throws Exception {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ }
+}
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryIntegrationTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryIntegrationTest.java
new file mode 100644
index 00000000..123b0bd2
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryIntegrationTest.java
@@ -0,0 +1,205 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobServiceClient;
+import com.azure.storage.blob.BlobServiceClientBuilder;
+import com.azure.storage.blob.models.BlobItem;
+import com.microsoft.durabletask.DurableTaskClient;
+import com.microsoft.durabletask.DurableTaskGrpcClientBuilder;
+import com.microsoft.durabletask.DurableTaskGrpcWorker;
+import com.microsoft.durabletask.DurableTaskGrpcWorkerBuilder;
+import com.microsoft.durabletask.OrchestrationMetadata;
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+import com.microsoft.durabletask.TaskOrchestration;
+import com.microsoft.durabletask.TaskOrchestrationFactory;
+import com.microsoft.durabletask.azuremanaged.DurableTaskSchedulerClientOptions;
+import com.microsoft.durabletask.azuremanaged.DurableTaskSchedulerWorkerOptions;
+
+import io.grpc.Channel;
+import io.grpc.ManagedChannel;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeoutException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration tests for the export history feature.
+ *
+ * These tests require:
+ *
+ * - DTS emulator (>= v0.4.22 for ListInstanceIds) on localhost:4001:
+ * {@code docker run --name durabletask-emulator -p 4001:8080 -d mcr.microsoft.com/dts/dts-emulator:latest}
+ * - Azurite on localhost:10000:
+ * {@code docker run --name azurite -p 10000:10000 -d mcr.microsoft.com/azure-storage/azurite}
+ *
+ */
+@Tag("integration")
+public class ExportHistoryIntegrationTest {
+
+ private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30);
+ private static final String AZURITE_CONNECTION_STRING =
+ "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;"
+ + "AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;"
+ + "BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;";
+
+ private static final String EMULATOR_ENDPOINT =
+ System.getenv("DTS_ENDPOINT") != null ? System.getenv("DTS_ENDPOINT") : "http://localhost:4001";
+
+ private static final String ECHO_ORCHESTRATION = "ExportHistoryEcho";
+
+ private DurableTaskGrpcWorker worker;
+ private DurableTaskClient client;
+ private ManagedChannel workerChannel;
+ private ManagedChannel clientChannel;
+
+ @AfterEach
+ void tearDown() {
+ if (worker != null) {
+ worker.stop();
+ worker = null;
+ }
+ if (client != null) {
+ try {
+ client.close();
+ } catch (Exception e) {
+ // ignore
+ }
+ client = null;
+ }
+ if (workerChannel != null) {
+ workerChannel.shutdownNow();
+ workerChannel = null;
+ }
+ if (clientChannel != null) {
+ clientChannel.shutdownNow();
+ clientChannel = null;
+ }
+ }
+
+ @Test
+ void batchExport_writesOneBlobPerCompletedInstance() throws TimeoutException, InterruptedException {
+ int instanceCount = 3;
+ String container = "exporthistory-it-" + System.currentTimeMillis();
+
+ ExportHistoryStorageOptions storage = new ExportHistoryStorageOptions()
+ .setConnectionString(AZURITE_CONNECTION_STRING)
+ .setContainerName(container);
+
+ this.client = createClientBuilder().build();
+
+ DurableTaskGrpcWorkerBuilder workerBuilder = createWorkerBuilder();
+ workerBuilder.addOrchestration(new TaskOrchestrationFactory() {
+ @Override
+ public String getName() {
+ return ECHO_ORCHESTRATION;
+ }
+
+ @Override
+ public TaskOrchestration create() {
+ return ctx -> ctx.complete(ctx.getInput(String.class));
+ }
+ });
+ ExportHistoryWorkerExtensions.useExportHistory(workerBuilder, storage, this.client);
+ this.worker = workerBuilder.build();
+ this.worker.start();
+
+ Instant windowStart = Instant.now().minusSeconds(60);
+
+ // Schedule and complete some orchestrations to export.
+ List instanceIds = new ArrayList<>();
+ for (int i = 0; i < instanceCount; i++) {
+ String id = this.client.scheduleNewOrchestrationInstance(ECHO_ORCHESTRATION, "payload-" + i);
+ instanceIds.add(id);
+ }
+ for (String id : instanceIds) {
+ OrchestrationMetadata md = this.client.waitForInstanceCompletion(id, DEFAULT_TIMEOUT, false);
+ assertEquals(OrchestrationRuntimeStatus.COMPLETED, md.getRuntimeStatus());
+ }
+
+ Instant windowEnd = Instant.now();
+
+ // Create the export job.
+ ExportHistoryClient export = ExportHistoryClientExtensions.useExportHistory(this.client, storage);
+ ExportHistoryJobClient jobClient = export.createJob(new ExportJobCreationOptions("it-job-" + System.currentTimeMillis())
+ .setMode(ExportMode.BATCH)
+ .setCompletedTimeFrom(windowStart)
+ .setCompletedTimeTo(windowEnd)
+ .setMaxInstancesPerBatch(10));
+
+ // Wait for the job to complete.
+ ExportJobDescription description = waitForJobCompletion(jobClient, Duration.ofSeconds(60));
+ assertNotNull(description);
+ assertEquals(ExportJobStatus.COMPLETED, description.getStatus());
+ assertTrue(description.getExportedInstances() >= instanceCount,
+ "Expected at least " + instanceCount + " exported instances, got " + description.getExportedInstances());
+
+ // Verify the blobs were written.
+ long blobCount = countBlobs(container);
+ assertTrue(blobCount >= instanceCount,
+ "Expected at least " + instanceCount + " blobs in container " + container + ", found " + blobCount);
+ }
+
+ private ExportJobDescription waitForJobCompletion(ExportHistoryJobClient jobClient, Duration timeout)
+ throws InterruptedException {
+ Instant deadline = Instant.now().plus(timeout);
+ ExportJobDescription description = jobClient.describe();
+ while (Instant.now().isBefore(deadline)) {
+ description = jobClient.describe();
+ if (description.getStatus() == ExportJobStatus.COMPLETED
+ || description.getStatus() == ExportJobStatus.FAILED) {
+ return description;
+ }
+ Thread.sleep(2000);
+ }
+ return description;
+ }
+
+ private static long countBlobs(String container) {
+ BlobServiceClient serviceClient = new BlobServiceClientBuilder()
+ .connectionString(AZURITE_CONNECTION_STRING)
+ .buildClient();
+ BlobContainerClient containerClient = serviceClient.getBlobContainerClient(container);
+ if (!containerClient.exists()) {
+ return 0;
+ }
+ long count = 0;
+ for (BlobItem ignored : containerClient.listBlobs()) {
+ count++;
+ }
+ return count;
+ }
+
+ private DurableTaskGrpcWorkerBuilder createWorkerBuilder() {
+ DurableTaskSchedulerWorkerOptions options = new DurableTaskSchedulerWorkerOptions()
+ .setEndpointAddress(EMULATOR_ENDPOINT)
+ .setTaskHubName("default")
+ .setCredential(null)
+ .setAllowInsecureCredentials(true);
+ Channel grpcChannel = options.createGrpcChannel();
+ this.workerChannel = (ManagedChannel) grpcChannel;
+ return new DurableTaskGrpcWorkerBuilder().grpcChannel(grpcChannel);
+ }
+
+ private DurableTaskGrpcClientBuilder createClientBuilder() {
+ DurableTaskSchedulerClientOptions options = new DurableTaskSchedulerClientOptions()
+ .setEndpointAddress(EMULATOR_ENDPOINT)
+ .setTaskHubName("default")
+ .setCredential(null)
+ .setAllowInsecureCredentials(true);
+ Channel grpcChannel = options.createGrpcChannel();
+ this.clientChannel = (ManagedChannel) grpcChannel;
+ return new DurableTaskGrpcClientBuilder().grpcChannel(grpcChannel);
+ }
+}
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClientTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClientTest.java
new file mode 100644
index 00000000..e91615bd
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClientTest.java
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.DurableTaskClient;
+import com.microsoft.durabletask.OrchestrationMetadata;
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+
+import java.time.Duration;
+import java.util.concurrent.TimeoutException;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** Unit tests for {@link ExportHistoryJobClient}. */
+class ExportHistoryJobClientTest {
+
+ @Test
+ void delete_terminatesWaitsForAndPurgesExportOrchestrator() throws TimeoutException {
+ DurableTaskClient durableTaskClient = mock(DurableTaskClient.class);
+ OrchestrationMetadata deleteOperation = mock(OrchestrationMetadata.class);
+ OrchestrationMetadata exportOrchestrator = mock(OrchestrationMetadata.class);
+ String operationInstanceId = "delete-operation";
+ String exportOrchestratorInstanceId = ExportHistoryConstants.getOrchestratorInstanceId("job-1");
+
+ when(durableTaskClient.scheduleNewOrchestrationInstance(
+ eq(ExecuteExportJobOperationOrchestrator.NAME), any(ExportJobOperationRequest.class)))
+ .thenReturn(operationInstanceId);
+ when(durableTaskClient.waitForInstanceCompletion(
+ eq(operationInstanceId), any(Duration.class), eq(true)))
+ .thenReturn(deleteOperation);
+ when(deleteOperation.getRuntimeStatus()).thenReturn(OrchestrationRuntimeStatus.COMPLETED);
+ when(durableTaskClient.waitForInstanceCompletion(
+ eq(exportOrchestratorInstanceId), any(Duration.class), eq(false)))
+ .thenReturn(exportOrchestrator);
+
+ ExportHistoryJobClient client = new ExportHistoryJobClient(
+ durableTaskClient, "job-1", new ExportHistoryStorageOptions());
+ client.delete();
+
+ InOrder calls = inOrder(durableTaskClient);
+ calls.verify(durableTaskClient).scheduleNewOrchestrationInstance(
+ eq(ExecuteExportJobOperationOrchestrator.NAME), any(ExportJobOperationRequest.class));
+ calls.verify(durableTaskClient).waitForInstanceCompletion(
+ eq(operationInstanceId), any(Duration.class), eq(true));
+ calls.verify(durableTaskClient).terminate(exportOrchestratorInstanceId, "Export job deleted");
+ calls.verify(durableTaskClient).waitForInstanceCompletion(
+ eq(exportOrchestratorInstanceId), any(Duration.class), eq(false));
+ calls.verify(durableTaskClient).purgeInstance(exportOrchestratorInstanceId);
+ }
+
+ @Test
+ void delete_failedEntityOperationIsSurfacedWithoutCleaningUpRunner() throws TimeoutException {
+ DurableTaskClient durableTaskClient = mock(DurableTaskClient.class);
+ OrchestrationMetadata deleteOperation = mock(OrchestrationMetadata.class);
+ String operationInstanceId = "delete-operation";
+ String exportOrchestratorInstanceId = ExportHistoryConstants.getOrchestratorInstanceId("job-1");
+
+ when(durableTaskClient.scheduleNewOrchestrationInstance(
+ eq(ExecuteExportJobOperationOrchestrator.NAME), any(ExportJobOperationRequest.class)))
+ .thenReturn(operationInstanceId);
+ when(durableTaskClient.waitForInstanceCompletion(
+ eq(operationInstanceId), any(Duration.class), eq(true)))
+ .thenReturn(deleteOperation);
+ when(deleteOperation.getRuntimeStatus()).thenReturn(OrchestrationRuntimeStatus.FAILED);
+
+ ExportHistoryJobClient client = new ExportHistoryJobClient(
+ durableTaskClient, "job-1", new ExportHistoryStorageOptions());
+
+ assertThrows(ExportJobClientValidationException.class, client::delete);
+ verify(durableTaskClient, never()).terminate(eq(exportOrchestratorInstanceId), any());
+ verify(durableTaskClient, never()).purgeInstance(exportOrchestratorInstanceId);
+ }
+}
\ No newline at end of file
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptionsTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptionsTest.java
new file mode 100644
index 00000000..08747e46
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptionsTest.java
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/** API parity tests for {@link ExportHistoryStorageOptions}. */
+class ExportHistoryStorageOptionsTest {
+
+ @Test
+ void formatIsConfiguredPerJobNotOnStorageOptions() {
+ assertFalse(Arrays.stream(ExportHistoryStorageOptions.class.getDeclaredMethods())
+ .map(Method::getName)
+ .anyMatch(name -> name.equals("getFormat") || name.equals("setFormat")));
+ }
+}
\ No newline at end of file
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensionsTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensionsTest.java
new file mode 100644
index 00000000..870214e8
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensionsTest.java
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.azure.core.credential.TokenCredential;
+import com.microsoft.durabletask.DurableTaskClient;
+import com.microsoft.durabletask.DurableTaskGrpcWorkerBuilder;
+import com.microsoft.durabletask.TaskEntity;
+import com.microsoft.durabletask.TaskEntityFactory;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.net.URI;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+
+/** Unit tests for {@link ExportHistoryWorkerExtensions}. */
+class ExportHistoryWorkerExtensionsTest {
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void registeredExportJobFactoryCreatesPackagePrivateEntity() throws Exception {
+ DurableTaskGrpcWorkerBuilder builder = new DurableTaskGrpcWorkerBuilder();
+ ExportHistoryStorageOptions storage = new ExportHistoryStorageOptions()
+ .setAccountUri(URI.create("https://example.blob.core.windows.net"))
+ .setCredential(mock(TokenCredential.class));
+
+ ExportHistoryWorkerExtensions.useExportHistory(builder, storage, mock(DurableTaskClient.class));
+
+ Field factoriesField = DurableTaskGrpcWorkerBuilder.class.getDeclaredField("entityFactories");
+ factoriesField.setAccessible(true);
+ Map factories =
+ (Map) factoriesField.get(builder);
+ TaskEntityFactory factory = factories.get(ExportJob.NAME.toLowerCase(java.util.Locale.ROOT));
+
+ assertNotNull(factory);
+ assertInstanceOf(ExportJob.class, factory.create());
+ }
+}
\ No newline at end of file
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptionsTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptionsTest.java
new file mode 100644
index 00000000..eb049170
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptionsTest.java
@@ -0,0 +1,145 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link ExportJobCreationOptions}.
+ */
+class ExportJobCreationOptionsTest {
+
+ @Test
+ void defaults_areCorrect() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1");
+ assertEquals("job-1", options.getJobId());
+ assertEquals(ExportMode.BATCH, options.getMode());
+ assertEquals(100, options.getMaxInstancesPerBatch());
+ assertEquals(ExportFormatKind.JSONL, options.getFormat().getKind());
+ assertEquals(
+ Arrays.asList(
+ OrchestrationRuntimeStatus.COMPLETED,
+ OrchestrationRuntimeStatus.FAILED,
+ OrchestrationRuntimeStatus.TERMINATED),
+ options.getRuntimeStatus());
+ }
+
+ @Test
+ void nullOrEmptyJobId_generatesId() {
+ assertNotNull(new ExportJobCreationOptions(null).getJobId());
+ assertFalse(new ExportJobCreationOptions(null).getJobId().isEmpty());
+ assertFalse(new ExportJobCreationOptions("").getJobId().isEmpty());
+ }
+
+ @Test
+ void fluentSetters_returnSameInstance() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1");
+ assertSame(options, options.setMode(ExportMode.CONTINUOUS));
+ assertSame(options, options.setCompletedTimeFrom(Instant.now()));
+ assertSame(options, options.setCompletedTimeTo(null));
+ assertSame(options, options.setMaxInstancesPerBatch(50));
+ assertSame(options, options.setRuntimeStatus(null));
+ }
+
+ @Test
+ void setMaxInstancesPerBatch_outOfRange_throws() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1");
+ assertThrows(IllegalArgumentException.class, () -> options.setMaxInstancesPerBatch(0));
+ assertThrows(IllegalArgumentException.class, () -> options.setMaxInstancesPerBatch(1001));
+ }
+
+ @Test
+ void setMaxInstancesPerBatch_atBounds_succeeds() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1");
+ assertEquals(1, options.setMaxInstancesPerBatch(1).getMaxInstancesPerBatch());
+ assertEquals(1000, options.setMaxInstancesPerBatch(1000).getMaxInstancesPerBatch());
+ }
+
+ @Test
+ void setRuntimeStatus_nonTerminal_throws() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1");
+ assertThrows(IllegalArgumentException.class,
+ () -> options.setRuntimeStatus(Collections.singletonList(OrchestrationRuntimeStatus.RUNNING)));
+ }
+
+ @Test
+ void setRuntimeStatus_terminalSubset_succeeds() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1")
+ .setRuntimeStatus(Collections.singletonList(OrchestrationRuntimeStatus.COMPLETED));
+ assertEquals(Collections.singletonList(OrchestrationRuntimeStatus.COMPLETED), options.getRuntimeStatus());
+ }
+
+ @Test
+ void setRuntimeStatus_nullOrEmpty_resetsToAllTerminal() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1")
+ .setRuntimeStatus(Collections.singletonList(OrchestrationRuntimeStatus.COMPLETED))
+ .setRuntimeStatus(null);
+ assertEquals(3, options.getRuntimeStatus().size());
+ assertTrue(options.getRuntimeStatus().contains(OrchestrationRuntimeStatus.TERMINATED));
+ }
+
+ @Test
+ void validateForCreate_batchRequiresWindow() {
+ ExportJobCreationOptions noFrom = new ExportJobCreationOptions("job-1").setMode(ExportMode.BATCH);
+ assertThrows(IllegalArgumentException.class, noFrom::validateForCreate);
+
+ ExportJobCreationOptions noTo = new ExportJobCreationOptions("job-1")
+ .setMode(ExportMode.BATCH)
+ .setCompletedTimeFrom(Instant.now().minusSeconds(3600));
+ assertThrows(IllegalArgumentException.class, noTo::validateForCreate);
+ }
+
+ @Test
+ void validateForCreate_batchToBeforeFrom_throws() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1")
+ .setMode(ExportMode.BATCH)
+ .setCompletedTimeFrom(Instant.now().minusSeconds(60))
+ .setCompletedTimeTo(Instant.now().minusSeconds(120));
+ assertThrows(IllegalArgumentException.class, options::validateForCreate);
+ }
+
+ @Test
+ void validateForCreate_batchToInFuture_throws() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1")
+ .setMode(ExportMode.BATCH)
+ .setCompletedTimeFrom(Instant.now().minusSeconds(60))
+ .setCompletedTimeTo(Instant.now().plusSeconds(3600));
+ assertThrows(IllegalArgumentException.class, options::validateForCreate);
+ }
+
+ @Test
+ void validateForCreate_validBatch_passes() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1")
+ .setMode(ExportMode.BATCH)
+ .setCompletedTimeFrom(Instant.now().minusSeconds(3600))
+ .setCompletedTimeTo(Instant.now().minusSeconds(60));
+ options.validateForCreate();
+ }
+
+ @Test
+ void validateForCreate_continuousWithTo_throws() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1")
+ .setMode(ExportMode.CONTINUOUS)
+ .setCompletedTimeTo(Instant.now().minusSeconds(60));
+ assertThrows(IllegalArgumentException.class, options::validateForCreate);
+ }
+
+ @Test
+ void validateForCreate_continuousWithoutTo_passes() {
+ ExportJobCreationOptions options = new ExportJobCreationOptions("job-1")
+ .setMode(ExportMode.CONTINUOUS);
+ options.validateForCreate();
+ }
+}
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobDescriptionTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobDescriptionTest.java
new file mode 100644
index 00000000..580fad34
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobDescriptionTest.java
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+/**
+ * Unit tests for {@link ExportJobDescription} projection and {@link ExportFormat} value semantics.
+ */
+class ExportJobDescriptionTest {
+
+ @Test
+ void fromState_projectsAllFields() {
+ Instant created = Instant.parse("2026-06-30T10:00:00Z");
+ Instant modified = Instant.parse("2026-06-30T11:00:00Z");
+ Instant checkpointTime = Instant.parse("2026-06-30T10:30:00Z");
+
+ ExportJobConfiguration config = new ExportJobConfiguration();
+ ExportCheckpoint checkpoint = new ExportCheckpoint("cursor-1");
+
+ ExportJobState state = new ExportJobState();
+ state.setStatus(ExportJobStatus.ACTIVE);
+ state.setConfig(config);
+ state.setCheckpoint(checkpoint);
+ state.setCreatedAt(created);
+ state.setLastModifiedAt(modified);
+ state.setLastCheckpointTime(checkpointTime);
+ state.setLastError("oops");
+ state.setScannedInstances(10);
+ state.setExportedInstances(7);
+ state.setOrchestratorInstanceId("ExportJob-job-1");
+
+ ExportJobDescription d = ExportJobDescription.fromState("job-1", state);
+
+ assertEquals("job-1", d.getJobId());
+ assertEquals(ExportJobStatus.ACTIVE, d.getStatus());
+ assertSame(config, d.getConfig());
+ assertSame(checkpoint, d.getCheckpoint());
+ assertEquals(created, d.getCreatedAt());
+ assertEquals(modified, d.getLastModifiedAt());
+ assertEquals(checkpointTime, d.getLastCheckpointTime());
+ assertEquals("oops", d.getLastError());
+ assertEquals(10, d.getScannedInstances());
+ assertEquals(7, d.getExportedInstances());
+ assertEquals("ExportJob-job-1", d.getOrchestratorInstanceId());
+ }
+
+ @Test
+ void exportFormat_defaultIsJsonl() {
+ ExportFormat format = ExportFormat.getDefault();
+ assertEquals(ExportFormatKind.JSONL, format.getKind());
+ assertEquals(ExportFormat.DEFAULT_SCHEMA_VERSION, format.getSchemaVersion());
+ }
+
+ @Test
+ void exportFormat_equalsAndHashCode() {
+ ExportFormat a = new ExportFormat(ExportFormatKind.JSONL, "1.0");
+ ExportFormat b = new ExportFormat(ExportFormatKind.JSONL, "1.0");
+ ExportFormat c = new ExportFormat(ExportFormatKind.JSON, "1.0");
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ org.junit.jupiter.api.Assertions.assertNotEquals(a, c);
+ }
+
+ @Test
+ void exportHistoryConstants_orchestratorInstanceId() {
+ assertEquals("ExportJob-job-9", ExportHistoryConstants.getOrchestratorInstanceId("job-9"));
+ }
+}
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestratorTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestratorTest.java
new file mode 100644
index 00000000..66c4c3c7
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestratorTest.java
@@ -0,0 +1,67 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.EntityInstanceId;
+import com.microsoft.durabletask.Task;
+import com.microsoft.durabletask.TaskOrchestrationContext;
+import com.microsoft.durabletask.TaskOrchestrationEntityFeature;
+import com.microsoft.durabletask.interruption.ContinueAsNewInterruption;
+import com.microsoft.durabletask.interruption.OrchestratorBlockedException;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link ExportJobOrchestrator} control-flow handling.
+ *
+ * Regression guard: the Java Durable Task SDK suspends an orchestrator by throwing
+ * {@link OrchestratorBlockedException} from {@code await()} and {@link ContinueAsNewInterruption} from
+ * {@code continueAsNew()}. The orchestrator must rethrow these — never swallow them via a broad
+ * {@code catch (RuntimeException)} — or the orchestration gets stuck and the export never progresses.
+ */
+class ExportJobOrchestratorTest {
+
+ @Test
+ void run_rethrowsOrchestratorBlockedException() {
+ OrchestratorBlockedException blocked = new OrchestratorBlockedException("blocked");
+ TaskOrchestrationContext ctx = contextWhereGetThrows(blocked);
+
+ OrchestratorBlockedException thrown = assertThrows(
+ OrchestratorBlockedException.class, () -> new ExportJobOrchestrator().run(ctx));
+ assertSame(blocked, thrown);
+ }
+
+ @Test
+ void run_rethrowsContinueAsNewInterruption() {
+ ContinueAsNewInterruption interrupt = new ContinueAsNewInterruption("continueAsNew");
+ TaskOrchestrationContext ctx = contextWhereGetThrows(interrupt);
+
+ ContinueAsNewInterruption thrown = assertThrows(
+ ContinueAsNewInterruption.class, () -> new ExportJobOrchestrator().run(ctx));
+ assertSame(interrupt, thrown);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static TaskOrchestrationContext contextWhereGetThrows(RuntimeException toThrow) {
+ TaskOrchestrationContext ctx = mock(TaskOrchestrationContext.class);
+ when(ctx.getInput(ExportJobRunRequest.class))
+ .thenReturn(new ExportJobRunRequest(new EntityInstanceId("ExportJob", "job-1"), 0));
+ when(ctx.getIsReplaying()).thenReturn(true);
+
+ TaskOrchestrationEntityFeature entities = mock(TaskOrchestrationEntityFeature.class);
+ when(ctx.getEntities()).thenReturn(entities);
+
+ Task blockedTask = (Task) mock(Task.class);
+ when(blockedTask.await()).thenThrow(toThrow);
+ when(entities.callEntity(any(), eq(ExportJobTransitions.OP_GET), any(), eq(ExportJobState.class)))
+ .thenReturn(blockedTask);
+
+ return ctx;
+ }
+}
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTest.java
new file mode 100644
index 00000000..3df8eb31
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTest.java
@@ -0,0 +1,68 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.AbstractTaskEntity;
+import com.microsoft.durabletask.TaskEntityContext;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.time.Instant;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Unit tests for the {@link ExportJob} entity's {@code create} operation.
+ */
+class ExportJobTest {
+
+ @Test
+ void create_continuousWithoutCompletedTimeFrom_defaultsToCreationInstant() throws Exception {
+ ExportJob entity = newEntityWithPendingState();
+
+ Instant before = Instant.now();
+ entity.create(new ExportJobCreationOptions("job-continuous")
+ .setMode(ExportMode.CONTINUOUS)
+ .setDestination(new ExportDestination("container")));
+ Instant after = Instant.now();
+
+ Instant completedTimeFrom = entity.get().getConfig().getFilter().getCompletedTimeFrom();
+ assertNotNull(completedTimeFrom,
+ "CONTINUOUS create must default completedTimeFrom so it does not re-export the whole task hub");
+ assertFalse(completedTimeFrom.isBefore(before), "defaulted completedTimeFrom must be at/after job creation");
+ assertFalse(completedTimeFrom.isAfter(after), "defaulted completedTimeFrom must be at/before job creation");
+
+ assertEquals(entity.get().getCreatedAt(), completedTimeFrom);
+ }
+
+ @Test
+ void create_explicitCompletedTimeFrom_isPreserved() throws Exception {
+ ExportJob entity = newEntityWithPendingState();
+ Instant explicit = Instant.parse("2026-06-01T00:00:00Z");
+
+ entity.create(new ExportJobCreationOptions("job-explicit")
+ .setMode(ExportMode.CONTINUOUS)
+ .setCompletedTimeFrom(explicit)
+ .setDestination(new ExportDestination("container")));
+
+ assertEquals(explicit, entity.get().getConfig().getFilter().getCompletedTimeFrom());
+ }
+
+ private static ExportJob newEntityWithPendingState() throws Exception {
+ ExportJob entity = new ExportJob();
+ ExportJobState state = new ExportJobState();
+ state.setStatus(ExportJobStatus.PENDING);
+ setBaseField(entity, "state", state);
+ setBaseField(entity, "context", mock(TaskEntityContext.class));
+ return entity;
+ }
+
+ private static void setBaseField(Object target, String name, Object value) throws Exception {
+ Field field = AbstractTaskEntity.class.getDeclaredField(name);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+}
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTransitionsTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTransitionsTest.java
new file mode 100644
index 00000000..6eaf6103
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTransitionsTest.java
@@ -0,0 +1,68 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link ExportJobTransitions}.
+ */
+class ExportJobTransitionsTest {
+
+ @Test
+ void create_fromTerminalOrPending_toActive_isValid() {
+ assertTrue(ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_CREATE, ExportJobStatus.PENDING, ExportJobStatus.ACTIVE));
+ assertTrue(ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_CREATE, ExportJobStatus.FAILED, ExportJobStatus.ACTIVE));
+ assertTrue(ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_CREATE, ExportJobStatus.COMPLETED, ExportJobStatus.ACTIVE));
+ }
+
+ @Test
+ void create_fromActive_isInvalid() {
+ assertFalse(ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_CREATE, ExportJobStatus.ACTIVE, ExportJobStatus.ACTIVE));
+ }
+
+ @Test
+ void create_toNonActive_isInvalid() {
+ assertFalse(ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_CREATE, ExportJobStatus.PENDING, ExportJobStatus.COMPLETED));
+ }
+
+ @Test
+ void markAsCompleted_fromActive_isValid() {
+ assertTrue(ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_MARK_AS_COMPLETED, ExportJobStatus.ACTIVE, ExportJobStatus.COMPLETED));
+ }
+
+ @Test
+ void markAsCompleted_fromNonActive_isInvalid() {
+ assertFalse(ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_MARK_AS_COMPLETED, ExportJobStatus.PENDING, ExportJobStatus.COMPLETED));
+ }
+
+ @Test
+ void markAsFailed_fromActive_isValid() {
+ assertTrue(ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_MARK_AS_FAILED, ExportJobStatus.ACTIVE, ExportJobStatus.FAILED));
+ }
+
+ @Test
+ void markAsFailed_fromTerminal_isInvalid() {
+ assertFalse(ExportJobTransitions.isValidTransition(
+ ExportJobTransitions.OP_MARK_AS_FAILED, ExportJobStatus.COMPLETED, ExportJobStatus.FAILED));
+ }
+
+ @Test
+ void unknownOrNullOperation_isInvalid() {
+ assertFalse(ExportJobTransitions.isValidTransition(
+ "NotAnOperation", ExportJobStatus.ACTIVE, ExportJobStatus.COMPLETED));
+ assertFalse(ExportJobTransitions.isValidTransition(
+ null, ExportJobStatus.ACTIVE, ExportJobStatus.COMPLETED));
+ }
+}
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerParityTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerParityTest.java
new file mode 100644
index 00000000..65dbf18a
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerParityTest.java
@@ -0,0 +1,161 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.microsoft.durabletask.FailureDetails;
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+import com.microsoft.durabletask.history.ContinueAsNewEvent;
+import com.microsoft.durabletask.history.EntityLockGrantedEvent;
+import com.microsoft.durabletask.history.EventRaisedEvent;
+import com.microsoft.durabletask.history.EventSentEvent;
+import com.microsoft.durabletask.history.ExecutionCompletedEvent;
+import com.microsoft.durabletask.history.ExecutionResumedEvent;
+import com.microsoft.durabletask.history.ExecutionRewoundEvent;
+import com.microsoft.durabletask.history.ExecutionStartedEvent;
+import com.microsoft.durabletask.history.ExecutionSuspendedEvent;
+import com.microsoft.durabletask.history.ExecutionTerminatedEvent;
+import com.microsoft.durabletask.history.GenericEvent;
+import com.microsoft.durabletask.history.HistoryEvent;
+import com.microsoft.durabletask.history.HistoryStateEvent;
+import com.microsoft.durabletask.history.OrchestrationInstance;
+import com.microsoft.durabletask.history.OrchestrationState;
+import com.microsoft.durabletask.history.OrchestratorCompletedEvent;
+import com.microsoft.durabletask.history.OrchestratorStartedEvent;
+import com.microsoft.durabletask.history.ParentInstanceInfo;
+import com.microsoft.durabletask.history.SubOrchestrationInstanceCompletedEvent;
+import com.microsoft.durabletask.history.SubOrchestrationInstanceCreatedEvent;
+import com.microsoft.durabletask.history.SubOrchestrationInstanceFailedEvent;
+import com.microsoft.durabletask.history.TaskCompletedEvent;
+import com.microsoft.durabletask.history.TaskFailedEvent;
+import com.microsoft.durabletask.history.TaskScheduledEvent;
+import com.microsoft.durabletask.history.TimerCreatedEvent;
+import com.microsoft.durabletask.history.TimerFiredEvent;
+import org.junit.jupiter.api.Test;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Constructor;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Pins {@link HistoryEventSerializer} output against golden JSON captured from the reference export implementation.
+ * The golden lines live in {@code src/test/resources/golden/reference-history-events.jsonl} and must match
+ * byte-for-byte.
+ */
+class HistoryEventSerializerParityTest {
+
+ private static final Instant TS = Instant.parse("2026-06-30T12:00:00Z");
+ private static final Instant FIRE = Instant.parse("2026-06-30T12:05:00Z");
+ private static final ExportFormat JSONL = new ExportFormat(ExportFormatKind.JSONL, "1.0");
+ private static final ExportFormat JSON = new ExportFormat(ExportFormatKind.JSON, "1.0");
+
+ @Test
+ void serializesEachEventByteForByteAgainstReference() throws Exception {
+ List golden = readGolden();
+ List events = buildEvents();
+ assertEquals(golden.size(), events.size(), "golden line count vs event count");
+
+ for (int i = 0; i < events.size(); i++) {
+ HistoryEvent event = events.get(i);
+ String actual = HistoryEventSerializer.serialize(Collections.singletonList(event), JSONL);
+ assertEquals(golden.get(i) + "\n", actual,
+ "byte mismatch at index " + i + " (" + event.getClass().getSimpleName() + ")");
+ }
+ }
+
+ @Test
+ void serializesJsonArrayFormat() throws Exception {
+ List golden = readGolden();
+ List two = Arrays.asList(
+ new OrchestratorStartedEvent(0, TS),
+ new GenericEvent(15, TS, "some-data"));
+ String actual = HistoryEventSerializer.serialize(two, JSON);
+ // golden index 0 = OrchestratorStarted, index 19 = GenericEvent.
+ assertEquals("[" + golden.get(0) + "," + golden.get(19) + "]", actual);
+ }
+
+ @Test
+ void escapesStringsLikeReferenceEncoder() throws Exception {
+ HistoryEvent event = new EventRaisedEvent(0, TS, "n", "caf\u00e9 \uD83C\uDF89 a&bd'e+f`g");
+ String actual = HistoryEventSerializer.serialize(Collections.singletonList(event), JSONL).trim();
+ String expectedInput = "caf\\u00E9 \\uD83C\\uDF89 a\\u0026b\\u003Cc\\u003Ed\\u0027e\\u002Bf\\u0060g";
+ assertTrue(actual.contains("\"input\":\"" + expectedInput + "\""), actual);
+ }
+
+ @Test
+ void entityEventGetsJavaNativeEventTypeDiscriminator() throws Exception {
+ HistoryEvent event = new EntityLockGrantedEvent(3, TS, "cs-1");
+ String actual = HistoryEventSerializer.serialize(Collections.singletonList(event), JSONL).trim();
+ assertTrue(actual.startsWith("{\"eventType\":\"EntityLockGranted\""), actual);
+ assertTrue(actual.contains("\"eventId\":3"), actual);
+ }
+
+ private static List buildEvents() throws Exception {
+ FailureDetails inner = failure("System.NullReferenceException", "npe", " at Bar()", true, null);
+ FailureDetails outer = failure("System.InvalidOperationException", "boom", " at Foo()", false, inner);
+
+ return Arrays.asList(
+ new OrchestratorStartedEvent(0, TS),
+ new OrchestratorCompletedEvent(0, TS),
+ new ExecutionStartedEvent(0, TS, "ProcessOrder", "2.1", "\"widget\"",
+ new OrchestrationInstance("order-42", "e1"),
+ new ParentInstanceInfo(5, "Parent", "1.0", new OrchestrationInstance("parent-1", "pe1")),
+ FIRE, null, null, Collections.emptyMap()),
+ new ExecutionCompletedEvent(1, TS, OrchestrationRuntimeStatus.COMPLETED, "\"done\"", null),
+ new ExecutionCompletedEvent(1, TS, OrchestrationRuntimeStatus.FAILED, null, outer),
+ new ExecutionTerminatedEvent(2, TS, "\"stop\"", false),
+ new ExecutionSuspendedEvent(3, TS, "\"pause\""),
+ new ExecutionResumedEvent(4, TS, "\"go\""),
+ new ExecutionRewoundEvent(5, TS, null, null, null, null, null, null, null, null, null),
+ new TaskScheduledEvent(6, TS, "ChargeCard", null, "\"widget\"", null,
+ Collections.singletonMap("env", "prod")),
+ new TaskCompletedEvent(7, TS, 6, "\"charged\""),
+ new TaskFailedEvent(8, TS, 6, outer),
+ new SubOrchestrationInstanceCreatedEvent(9, TS, "child-1", "ChildOrch", "1.0", "\"sub\"", null,
+ Collections.emptyMap()),
+ new SubOrchestrationInstanceCompletedEvent(10, TS, 9, "\"subdone\""),
+ new SubOrchestrationInstanceFailedEvent(11, TS, 9, outer),
+ new TimerCreatedEvent(12, TS, FIRE),
+ new TimerFiredEvent(99, TS, FIRE, 12),
+ new EventSentEvent(13, TS, "target-1", "approve", "\"payload\""),
+ new EventRaisedEvent(14, TS, "approve", "\"payload\""),
+ new GenericEvent(15, TS, "some-data"),
+ new ContinueAsNewEvent(16, TS, "\"nextInput\""),
+ new HistoryStateEvent(17, TS, new OrchestrationState(
+ "order-42", "ProcessOrder", "1.0", OrchestrationRuntimeStatus.COMPLETED,
+ FIRE, TS, TS, null, "\"widget\"", "\"done\"", "custom-status", null, null, null,
+ Collections.emptyMap())));
+ }
+
+ private static List readGolden() throws Exception {
+ List lines = new ArrayList<>();
+ try (InputStream in = HistoryEventSerializerParityTest.class
+ .getResourceAsStream("/golden/reference-history-events.jsonl");
+ BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ lines.add(line);
+ }
+ }
+ return lines;
+ }
+
+ private static FailureDetails failure(
+ String errorType, String message, String stackTrace, boolean nonRetriable, FailureDetails inner)
+ throws Exception {
+ Constructor ctor = FailureDetails.class.getDeclaredConstructor(
+ String.class, String.class, String.class, boolean.class, FailureDetails.class, Map.class);
+ ctor.setAccessible(true);
+ return ctor.newInstance(errorType, message, stackTrace, nonRetriable, inner, null);
+ }
+}
diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerTest.java
new file mode 100644
index 00000000..81e1d611
--- /dev/null
+++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerTest.java
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.microsoft.durabletask.exporthistory;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.microsoft.durabletask.history.GenericEvent;
+import com.microsoft.durabletask.history.HistoryEvent;
+import com.microsoft.durabletask.history.TaskCompletedEvent;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link HistoryEventSerializer}.
+ */
+class HistoryEventSerializerTest {
+
+ private static final Instant TS = Instant.parse("2026-06-30T12:00:00Z");
+
+ private static List sampleEvents() {
+ return Arrays.asList(
+ new TaskCompletedEvent(1, TS, 7, "\"42\""),
+ new GenericEvent(2, TS, "payload"));
+ }
+
+ @Test
+ void jsonl_oneEventPerLine() throws JsonProcessingException {
+ ExportFormat format = new ExportFormat(ExportFormatKind.JSONL, "1.0");
+ String result = HistoryEventSerializer.serialize(sampleEvents(), format);
+
+ String[] lines = result.split("\n");
+ assertEquals(2, lines.length);
+ assertTrue(lines[0].contains("\"eventId\":1"));
+ assertTrue(lines[0].contains("\"taskScheduledId\":7"));
+ assertTrue(lines[1].contains("\"eventId\":2"));
+ assertTrue(lines[1].contains("payload"));
+ }
+
+ @Test
+ void jsonl_omitsNullFields() throws JsonProcessingException {
+ // GenericEvent with null data should not emit a "data" property.
+ ExportFormat format = new ExportFormat(ExportFormatKind.JSONL, "1.0");
+ String result = HistoryEventSerializer.serialize(
+ Arrays.asList((HistoryEvent) new GenericEvent(1, TS, null)), format);
+ assertFalse(result.contains("\"data\""));
+ assertTrue(result.contains("\"eventId\":1"));
+ }
+
+ @Test
+ void json_producesArray() throws JsonProcessingException {
+ ExportFormat format = new ExportFormat(ExportFormatKind.JSON, "1.0");
+ String result = HistoryEventSerializer.serialize(sampleEvents(), format).trim();
+ assertTrue(result.startsWith("["));
+ assertTrue(result.endsWith("]"));
+ }
+
+ @Test
+ void fileExtension_byFormat() {
+ assertEquals("jsonl.gz", HistoryEventSerializer.fileExtension(new ExportFormat(ExportFormatKind.JSONL, "1.0")));
+ assertEquals("json", HistoryEventSerializer.fileExtension(new ExportFormat(ExportFormatKind.JSON, "1.0")));
+ }
+
+ @Test
+ void isCompressed_byFormat() {
+ assertTrue(HistoryEventSerializer.isCompressed(new ExportFormat(ExportFormatKind.JSONL, "1.0")));
+ assertFalse(HistoryEventSerializer.isCompressed(new ExportFormat(ExportFormatKind.JSON, "1.0")));
+ }
+
+ @Test
+ void contentType_byFormat() {
+ assertEquals("application/jsonl+gzip",
+ HistoryEventSerializer.contentType(new ExportFormat(ExportFormatKind.JSONL, "1.0")));
+ assertEquals("application/json",
+ HistoryEventSerializer.contentType(new ExportFormat(ExportFormatKind.JSON, "1.0")));
+ }
+
+ @Test
+ void timestampSerialization_isLocaleInvariant() throws JsonProcessingException {
+ Locale original = Locale.getDefault(Locale.Category.FORMAT);
+ try {
+ Locale.setDefault(Locale.Category.FORMAT, Locale.forLanguageTag("ar-EG"));
+ String result = HistoryEventSerializer.serialize(
+ Arrays.asList((HistoryEvent) new GenericEvent(
+ 1, Instant.parse("2026-06-30T12:00:00.123Z"), "payload")),
+ new ExportFormat(ExportFormatKind.JSONL, "1.0"));
+ assertTrue(result.contains("\"timestamp\":\"2026-06-30T12:00:00.123Z\""));
+ } finally {
+ Locale.setDefault(Locale.Category.FORMAT, original);
+ }
+ }
+}
diff --git a/exporthistory/src/test/resources/golden/reference-history-events.jsonl b/exporthistory/src/test/resources/golden/reference-history-events.jsonl
new file mode 100644
index 00000000..01cc537f
--- /dev/null
+++ b/exporthistory/src/test/resources/golden/reference-history-events.jsonl
@@ -0,0 +1,22 @@
+{"eventType":"OrchestratorStarted","eventId":0,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"OrchestratorCompleted","eventId":0,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"ExecutionStarted","parentInstance":{"name":"Parent","orchestrationInstance":{"instanceId":"parent-1","executionId":"pe1"},"taskScheduleId":5,"version":"1.0"},"name":"ProcessOrder","version":"2.1","input":"\u0022widget\u0022","tags":{},"scheduledStartTime":"2026-06-30T12:05:00Z","orchestrationInstance":{"instanceId":"order-42","executionId":"e1"},"eventId":0,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"ExecutionCompleted","orchestrationStatus":"Completed","result":"\u0022done\u0022","eventId":1,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"ExecutionCompleted","orchestrationStatus":"Failed","failureDetails":{"errorType":"System.InvalidOperationException","errorMessage":"boom","stackTrace":" at Foo()","innerFailure":{"errorType":"System.NullReferenceException","errorMessage":"npe","stackTrace":" at Bar()","isNonRetriable":true},"isNonRetriable":false},"eventId":1,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"ExecutionTerminated","input":"\u0022stop\u0022","eventId":2,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"ExecutionSuspended","reason":"\u0022pause\u0022","eventId":3,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"ExecutionResumed","reason":"\u0022go\u0022","eventId":4,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"ExecutionRewound","eventId":5,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"TaskScheduled","name":"ChargeCard","input":"\u0022widget\u0022","tags":{"env":"prod"},"eventId":6,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"TaskCompleted","taskScheduledId":6,"result":"\u0022charged\u0022","eventId":7,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"TaskFailed","taskScheduledId":6,"failureDetails":{"errorType":"System.InvalidOperationException","errorMessage":"boom","stackTrace":" at Foo()","innerFailure":{"errorType":"System.NullReferenceException","errorMessage":"npe","stackTrace":" at Bar()","isNonRetriable":true},"isNonRetriable":false},"eventId":8,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"SubOrchestrationInstanceCreated","name":"ChildOrch","version":"1.0","instanceId":"child-1","input":"\u0022sub\u0022","eventId":9,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"SubOrchestrationInstanceCompleted","taskScheduledId":9,"result":"\u0022subdone\u0022","eventId":10,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"SubOrchestrationInstanceFailed","taskScheduledId":9,"failureDetails":{"errorType":"System.InvalidOperationException","errorMessage":"boom","stackTrace":" at Foo()","innerFailure":{"errorType":"System.NullReferenceException","errorMessage":"npe","stackTrace":" at Bar()","isNonRetriable":true},"isNonRetriable":false},"eventId":11,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"TimerCreated","fireAt":"2026-06-30T12:05:00Z","eventId":12,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"TimerFired","timerId":12,"fireAt":"2026-06-30T12:05:00Z","eventId":-1,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"EventSent","instanceId":"target-1","name":"approve","input":"\u0022payload\u0022","eventId":13,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"EventRaised","name":"approve","input":"\u0022payload\u0022","eventId":14,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"GenericEvent","data":"some-data","eventId":15,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"ContinueAsNew","orchestrationStatus":"ContinuedAsNew","result":"\u0022nextInput\u0022","eventId":16,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
+{"eventType":"HistoryState","state":{"scheduledStartTime":"2026-06-30T12:05:00Z","completedTime":"0001-01-01T00:00:00","compressedSize":0,"createdTime":"2026-06-30T12:00:00Z","input":"\u0022widget\u0022","lastUpdatedTime":"2026-06-30T12:00:00Z","name":"ProcessOrder","orchestrationInstance":{"instanceId":"order-42"},"orchestrationStatus":"Running","output":"\u0022done\u0022","size":0,"status":"custom-status","tags":{},"version":"1.0"},"eventId":17,"isPlayed":false,"timestamp":"2026-06-30T12:00:00Z"}
diff --git a/samples/build.gradle b/samples/build.gradle
index e87347cb..77c3f63b 100644
--- a/samples/build.gradle
+++ b/samples/build.gradle
@@ -32,6 +32,11 @@ task runLargePayloadSample(type: JavaExec) {
mainClass = 'io.durabletask.samples.LargePayloadSample'
}
+task runHistoryExportSample(type: JavaExec) {
+ classpath = sourceSets.main.runtimeClasspath
+ mainClass = 'io.durabletask.samples.HistoryExportSample'
+}
+
// --- Entity samples (require a Durable Task sidecar / DTS emulator on localhost:4001) ---
// When ENDPOINT is set (or the default applies), these tasks connect to a Durable Task
// Scheduler such as the DTS emulator. Override by setting ENDPOINT/TASKHUB env vars.
@@ -104,6 +109,7 @@ dependencies {
implementation project(':client')
implementation project(':azuremanaged')
implementation project(':azure-blob-payloads')
+ implementation project(':exporthistory')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation platform("org.springframework.boot:spring-boot-dependencies:2.7.18")
diff --git a/samples/src/main/java/io/durabletask/samples/HistoryExportSample.java b/samples/src/main/java/io/durabletask/samples/HistoryExportSample.java
new file mode 100644
index 00000000..7aa300fa
--- /dev/null
+++ b/samples/src/main/java/io/durabletask/samples/HistoryExportSample.java
@@ -0,0 +1,157 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package io.durabletask.samples;
+
+import com.microsoft.durabletask.DurableTaskClient;
+import com.microsoft.durabletask.DurableTaskGrpcClientBuilder;
+import com.microsoft.durabletask.DurableTaskGrpcWorker;
+import com.microsoft.durabletask.DurableTaskGrpcWorkerBuilder;
+import com.microsoft.durabletask.OrchestrationMetadata;
+import com.microsoft.durabletask.OrchestrationRuntimeStatus;
+import com.microsoft.durabletask.TaskOrchestration;
+import com.microsoft.durabletask.TaskOrchestrationFactory;
+import com.microsoft.durabletask.azuremanaged.DurableTaskSchedulerClientExtensions;
+import com.microsoft.durabletask.azuremanaged.DurableTaskSchedulerWorkerExtensions;
+import com.microsoft.durabletask.exporthistory.ExportHistoryClient;
+import com.microsoft.durabletask.exporthistory.ExportHistoryClientExtensions;
+import com.microsoft.durabletask.exporthistory.ExportHistoryJobClient;
+import com.microsoft.durabletask.exporthistory.ExportHistoryStorageOptions;
+import com.microsoft.durabletask.exporthistory.ExportHistoryWorkerExtensions;
+import com.microsoft.durabletask.exporthistory.ExportJobCreationOptions;
+import com.microsoft.durabletask.exporthistory.ExportJobDescription;
+import com.microsoft.durabletask.exporthistory.ExportJobStatus;
+import com.microsoft.durabletask.exporthistory.ExportMode;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeoutException;
+import java.util.logging.Logger;
+
+/**
+ * Demonstrates durable export of terminal orchestration history to Azure Blob Storage.
+ *
+ * This sample schedules a few short orchestrations, waits for them to complete, then creates a BATCH export job
+ * that archives their history (gzipped JSONL) to a blob container.
+ *
+ *
Prerequisites
+ *
+ * - DTS emulator: {@code docker run -d -p 8080:8080 mcr.microsoft.com/dts/dts-emulator:latest}
+ * - Azurite: {@code docker run -d -p 10000:10000 mcr.microsoft.com/azure-storage/azurite azurite-blob --blobHost 0.0.0.0}
+ *
+ *
+ * Running
+ *
+ * ./gradlew :samples:runHistoryExportSample
+ *
+ *
+ * Environment variables
+ *
+ * - {@code DURABLE_TASK_SCHEDULER_CONNECTION_STRING} — DTS connection string
+ * (default: {@code Endpoint=http://localhost:8080;TaskHub=default;Authentication=None})
+ * - {@code EXPORT_HISTORY_STORAGE_CONNECTION_STRING} — Azure Storage connection string
+ * (default: {@code UseDevelopmentStorage=true})
+ * - {@code EXPORT_HISTORY_CONTAINER} — blob container name (default: {@code orchestration-history})
+ *
+ */
+final class HistoryExportSample {
+
+ private static final Logger logger = Logger.getLogger(HistoryExportSample.class.getName());
+ private static final String ORCHESTRATION_NAME = "HistoryExportEcho";
+ private static final int INSTANCE_COUNT = 3;
+
+ private HistoryExportSample() {
+ }
+
+ public static void main(String[] args) throws InterruptedException, TimeoutException {
+ String schedulerConnectionString = envOrDefault(
+ "DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
+ "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None");
+ String storageConnectionString = envOrDefault(
+ "EXPORT_HISTORY_STORAGE_CONNECTION_STRING",
+ "UseDevelopmentStorage=true");
+ String container = envOrDefault("EXPORT_HISTORY_CONTAINER", "orchestration-history");
+
+ ExportHistoryStorageOptions storage = new ExportHistoryStorageOptions()
+ .setConnectionString(storageConnectionString)
+ .setContainerName(container)
+ .setPrefix("exports/");
+
+ // Client (also used by the export activities, which need a client to the same backend).
+ DurableTaskGrpcClientBuilder clientBuilder = new DurableTaskGrpcClientBuilder();
+ DurableTaskSchedulerClientExtensions.useDurableTaskScheduler(clientBuilder, schedulerConnectionString);
+ DurableTaskClient client = clientBuilder.build();
+
+ // Worker: register a sample orchestration plus the export entity/orchestrators/activities.
+ DurableTaskGrpcWorkerBuilder workerBuilder = new DurableTaskGrpcWorkerBuilder();
+ DurableTaskSchedulerWorkerExtensions.useDurableTaskScheduler(workerBuilder, schedulerConnectionString);
+ workerBuilder.addOrchestration(new TaskOrchestrationFactory() {
+ @Override
+ public String getName() {
+ return ORCHESTRATION_NAME;
+ }
+
+ @Override
+ public TaskOrchestration create() {
+ return ctx -> ctx.complete(ctx.getInput(String.class));
+ }
+ });
+ ExportHistoryWorkerExtensions.useExportHistory(workerBuilder, storage, client);
+
+ try (DurableTaskClient autoCloseClient = client;
+ DurableTaskGrpcWorker worker = workerBuilder.build()) {
+ worker.start();
+ logger.info("Worker started.");
+
+ Instant windowStart = Instant.now().minusSeconds(60);
+
+ List instanceIds = new ArrayList<>();
+ for (int i = 0; i < INSTANCE_COUNT; i++) {
+ instanceIds.add(autoCloseClient.scheduleNewOrchestrationInstance(ORCHESTRATION_NAME, "payload-" + i));
+ }
+ for (String id : instanceIds) {
+ OrchestrationMetadata md = autoCloseClient.waitForInstanceCompletion(id, Duration.ofSeconds(30), false);
+ logger.info("Instance " + id + " -> " + md.getRuntimeStatus());
+ }
+
+ Instant windowEnd = Instant.now();
+
+ ExportHistoryClient export = ExportHistoryClientExtensions.useExportHistory(autoCloseClient, storage);
+ ExportHistoryJobClient jobClient = export.createJob(new ExportJobCreationOptions("sample-export")
+ .setMode(ExportMode.BATCH)
+ .setCompletedTimeFrom(windowStart)
+ .setCompletedTimeTo(windowEnd));
+
+ logger.info("Created export job: " + jobClient.getJobId());
+
+ ExportJobDescription description = jobClient.describe();
+ Instant deadline = Instant.now().plus(Duration.ofSeconds(60));
+ while (Instant.now().isBefore(deadline)
+ && description.getStatus() != ExportJobStatus.COMPLETED
+ && description.getStatus() != ExportJobStatus.FAILED) {
+ Thread.sleep(2000);
+ description = jobClient.describe();
+ }
+
+ logger.info("=========== RESULTS ===========");
+ logger.info("Job status: " + description.getStatus());
+ logger.info("Scanned instances: " + description.getScannedInstances());
+ logger.info("Exported instances:" + description.getExportedInstances());
+ if (description.getLastError() != null) {
+ logger.warning("Last error: " + description.getLastError());
+ }
+
+ if (description.getStatus() != ExportJobStatus.COMPLETED) {
+ logger.severe("FAIL: export job did not complete. Status: " + description.getStatus());
+ System.exit(1);
+ }
+ logger.info("Export complete. Archived history written to container '" + container + "' under 'exports/'.");
+ }
+ }
+
+ private static String envOrDefault(String name, String defaultValue) {
+ String value = System.getenv(name);
+ return (value == null || value.isEmpty()) ? defaultValue : value;
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index afe0838e..03574d37 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -4,6 +4,7 @@ include ":client"
include ":azurefunctions"
include ":azuremanaged"
include ":azure-blob-payloads"
+include ":exporthistory"
include ":samples"
include ":samples-azure-functions"
include ":endtoendtests"