From 74d75524fabfaaa863e30466bb45a6d14336c7f9 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Tue, 30 Jun 2026 13:41:43 -0700 Subject: [PATCH 01/12] initial commit --- .../durabletask/DurableTaskClient.java | 28 ++ .../durabletask/DurableTaskGrpcClient.java | 31 +++ .../durabletask/HistoryEventConverter.java | 246 ++++++++++++++++++ .../durabletask/ListInstanceIdsQuery.java | 114 ++++++++ .../durabletask/ListInstanceIdsResult.java | 43 +++ .../history/ContinueAsNewEvent.java | 31 +++ .../history/EntityLockGrantedEvent.java | 29 +++ .../history/EntityLockRequestedEvent.java | 65 +++++ .../history/EntityOperationCalledEvent.java | 92 +++++++ .../EntityOperationCompletedEvent.java | 40 +++ .../history/EntityOperationFailedEvent.java | 42 +++ .../history/EntityOperationSignaledEvent.java | 72 +++++ .../history/EntityUnlockSentEvent.java | 53 ++++ .../durabletask/history/EventRaisedEvent.java | 39 +++ .../durabletask/history/EventSentEvent.java | 47 ++++ .../history/ExecutionCompletedEvent.java | 56 ++++ .../history/ExecutionResumedEvent.java | 31 +++ .../history/ExecutionRewoundEvent.java | 116 +++++++++ .../history/ExecutionStartedEvent.java | 115 ++++++++ .../history/ExecutionSuspendedEvent.java | 31 +++ .../history/ExecutionTerminatedEvent.java | 39 +++ .../durabletask/history/GenericEvent.java | 31 +++ .../durabletask/history/HistoryEvent.java | 40 +++ .../history/HistoryStateEvent.java | 34 +++ .../history/OrchestrationInstance.java | 35 +++ .../history/OrchestrationState.java | 175 +++++++++++++ .../history/OrchestratorCompletedEvent.java | 20 ++ .../history/OrchestratorStartedEvent.java | 20 ++ .../history/ParentInstanceInfo.java | 57 ++++ ...ubOrchestrationInstanceCompletedEvent.java | 40 +++ .../SubOrchestrationInstanceCreatedEvent.java | 84 ++++++ .../SubOrchestrationInstanceFailedEvent.java | 42 +++ .../history/TaskCompletedEvent.java | 39 +++ .../durabletask/history/TaskFailedEvent.java | 42 +++ .../history/TaskScheduledEvent.java | 75 ++++++ .../history/TimerCreatedEvent.java | 29 +++ .../durabletask/history/TimerFiredEvent.java | 37 +++ .../durabletask/history/TraceContext.java | 35 +++ .../HistoryEventConverterTest.java | 226 ++++++++++++++++ .../durabletask/IntegrationTests.java | 70 +++++ 40 files changed, 2491 insertions(+) create mode 100644 client/src/main/java/com/microsoft/durabletask/HistoryEventConverter.java create mode 100644 client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java create mode 100644 client/src/main/java/com/microsoft/durabletask/ListInstanceIdsResult.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/ContinueAsNewEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/EntityLockGrantedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/EntityLockRequestedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/EntityOperationCalledEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/EntityOperationCompletedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/EntityOperationFailedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/EntityOperationSignaledEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/EntityUnlockSentEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/EventRaisedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/EventSentEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/ExecutionCompletedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/ExecutionResumedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/ExecutionRewoundEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/ExecutionStartedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/ExecutionSuspendedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/ExecutionTerminatedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/GenericEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/HistoryEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/HistoryStateEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/OrchestrationInstance.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/OrchestrationState.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/OrchestratorCompletedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/OrchestratorStartedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/ParentInstanceInfo.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceCompletedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceCreatedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/SubOrchestrationInstanceFailedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/TaskCompletedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/TaskFailedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/TaskScheduledEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/TimerCreatedEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/TimerFiredEvent.java create mode 100644 client/src/main/java/com/microsoft/durabletask/history/TraceContext.java create mode 100644 client/src/test/java/com/microsoft/durabletask/HistoryEventConverterTest.java diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskClient.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskClient.java index 117f9d69..5c1a4672 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskClient.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskClient.java @@ -2,8 +2,11 @@ // Licensed under the MIT License. package com.microsoft.durabletask; +import com.microsoft.durabletask.history.HistoryEvent; + import javax.annotation.Nullable; import java.time.Duration; +import java.util.List; import java.util.concurrent.TimeoutException; /** @@ -226,6 +229,31 @@ public abstract OrchestrationMetadata waitForInstanceCompletion( */ public abstract OrchestrationStatusQueryResult queryInstances(OrchestrationStatusQuery query); + /** + * Lists the IDs of terminal orchestration instances that completed within a time window. + *

+ * Unlike {@link #queryInstances(OrchestrationStatusQuery)}, which filters by creation time and returns full + * metadata, this method filters by completion time and returns only instance IDs, making it efficient + * for bulk enumeration such as archival/export. Results are paged; pass + * {@link ListInstanceIdsResult#getContinuationToken()} back via + * {@link ListInstanceIdsQuery#setContinuationToken(String)} to fetch subsequent pages. + * + * @param query filter criteria: completion-time window, terminal runtime statuses, page size, and pagination cursor + * @return a page of matching instance IDs and a cursor for the next page + */ + public abstract ListInstanceIdsResult listInstanceIds(ListInstanceIdsQuery query); + + /** + * Gets the full history of an orchestration instance as an ordered list of {@link HistoryEvent} objects. + *

+ * The events are returned in execution order. This is useful for archiving or offline analysis of an instance's + * execution history. Use {@code instanceof} to inspect each concrete event type. + * + * @param instanceId the unique ID of the orchestration instance whose history to fetch + * @return the instance's history events in order; empty if the instance has no history + */ + public abstract List getOrchestrationHistory(String instanceId); + /** * Initializes the target task hub data store. *

diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java index 3c588cde..da4af209 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java @@ -4,6 +4,8 @@ import com.google.protobuf.StringValue; import com.google.protobuf.Timestamp; +import com.microsoft.durabletask.history.HistoryEvent; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService; import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.*; import com.microsoft.durabletask.implementation.protobuf.TaskHubSidecarServiceGrpc; import com.microsoft.durabletask.implementation.protobuf.TaskHubSidecarServiceGrpc.*; @@ -295,6 +297,35 @@ private OrchestrationStatusQueryResult toQueryResult(QueryInstancesResponse quer return new OrchestrationStatusQueryResult(metadataList, queryInstancesResponse.getContinuationToken().getValue()); } + @Override + public ListInstanceIdsResult listInstanceIds(ListInstanceIdsQuery query) { + ListInstanceIdsRequest.Builder builder = ListInstanceIdsRequest.newBuilder(); + Optional.ofNullable(query.getCompletedTimeFrom()).ifPresent(from -> builder.setCompletedTimeFrom(DataConverter.getTimestampFromInstant(from))); + Optional.ofNullable(query.getCompletedTimeTo()).ifPresent(to -> builder.setCompletedTimeTo(DataConverter.getTimestampFromInstant(to))); + String requestContinuationToken = query.getContinuationToken() != null ? query.getContinuationToken() : ""; + builder.setLastInstanceKey(StringValue.of(requestContinuationToken)); + builder.setPageSize(query.getPageSize()); + query.getRuntimeStatusList().forEach(runtimeStatus -> Optional.ofNullable(runtimeStatus).ifPresent(status -> builder.addRuntimeStatus(OrchestrationRuntimeStatus.toProtobuf(status)))); + ListInstanceIdsResponse response = this.sidecarClient.listInstanceIds(builder.build()); + String continuationToken = response.hasLastInstanceKey() ? response.getLastInstanceKey().getValue() : null; + return new ListInstanceIdsResult(new ArrayList<>(response.getInstanceIdsList()), continuationToken); + } + + @Override + public List getOrchestrationHistory(String instanceId) { + StreamInstanceHistoryRequest request = StreamInstanceHistoryRequest.newBuilder() + .setInstanceId(instanceId) + .build(); + List historyEvents = new ArrayList<>(); + Iterator 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/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..70c70fc4 --- /dev/null +++ b/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java @@ -0,0 +1,114 @@ +// 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; + 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 + * @return this query object + */ + public ListInstanceIdsQuery setPageSize(int pageSize) { + 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/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..1b9d6287 --- /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#streamInstanceHistory(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..79d32c34 --- /dev/null +++ b/client/src/main/java/com/microsoft/durabletask/history/OrchestrationState.java @@ -0,0 +1,175 @@ +// 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.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(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..1922a735 --- /dev/null +++ b/client/src/test/java/com/microsoft/durabletask/HistoryEventConverterTest.java @@ -0,0 +1,226 @@ +// 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.ExecutionCompletedEvent; +import com.microsoft.durabletask.history.ExecutionStartedEvent; +import com.microsoft.durabletask.history.GenericEvent; +import com.microsoft.durabletask.history.HistoryEvent; +import com.microsoft.durabletask.history.HistoryStateEvent; +import com.microsoft.durabletask.history.OrchestrationState; +import com.microsoft.durabletask.history.SubOrchestrationInstanceCompletedEvent; +import com.microsoft.durabletask.history.TaskCompletedEvent; +import com.microsoft.durabletask.history.TaskFailedEvent; +import com.microsoft.durabletask.history.TimerFiredEvent; +import com.microsoft.durabletask.implementation.protobuf.OrchestratorService; +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.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; + +/** + * 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 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"; From 0cd9ef9e38f6ebb9542472adfd2fa76cbd695330 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Wed, 1 Jul 2026 16:49:38 -0700 Subject: [PATCH 02/12] normalize null runtimeStatusList in ListInstanceIdsQuery --- .../durabletask/ListInstanceIdsQuery.java | 2 +- .../durabletask/ListInstanceIdsQueryTest.java | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 client/src/test/java/com/microsoft/durabletask/ListInstanceIdsQueryTest.java diff --git a/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java b/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java index 70c70fc4..15a08a1c 100644 --- a/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java +++ b/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java @@ -36,7 +36,7 @@ public ListInstanceIdsQuery() { * @return this query object */ public ListInstanceIdsQuery setRuntimeStatusList(@Nullable List runtimeStatusList) { - this.runtimeStatusList = runtimeStatusList; + this.runtimeStatusList = runtimeStatusList != null ? new ArrayList<>(runtimeStatusList) : new ArrayList<>(); return this; } 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..bd942ba7 --- /dev/null +++ b/client/src/test/java/com/microsoft/durabletask/ListInstanceIdsQueryTest.java @@ -0,0 +1,44 @@ +// 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.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()); + } +} From 06cd428876704fcd76c282231aba9b0e2997ca8e Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Thu, 2 Jul 2026 11:35:58 -0700 Subject: [PATCH 03/12] fix javadoc broken link and heading levels --- .../java/com/microsoft/durabletask/EntityQueryPageable.java | 4 ++-- .../java/com/microsoft/durabletask/TypedEntityMetadata.java | 2 +- .../com/microsoft/durabletask/TypedEntityQueryPageable.java | 2 +- .../java/com/microsoft/durabletask/history/HistoryEvent.java | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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/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/HistoryEvent.java b/client/src/main/java/com/microsoft/durabletask/history/HistoryEvent.java
index 1b9d6287..59511cca 100644
--- a/client/src/main/java/com/microsoft/durabletask/history/HistoryEvent.java
+++ b/client/src/main/java/com/microsoft/durabletask/history/HistoryEvent.java
@@ -7,7 +7,7 @@
 /**
  * Base class for the events that make up an orchestration instance's execution history.
  * 

- * Instances are obtained from {@link com.microsoft.durabletask.DurableTaskClient#streamInstanceHistory(String)}. Each + * 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. */ From 203d86daf1d198bcc24b618b98f2606ca05144da Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Thu, 2 Jul 2026 11:38:07 -0700 Subject: [PATCH 04/12] add Helpers.throwIfArgumentNull --- .../java/com/microsoft/durabletask/DurableTaskGrpcClient.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java index da4af209..02e62bf2 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java @@ -299,6 +299,7 @@ private OrchestrationStatusQueryResult toQueryResult(QueryInstancesResponse quer @Override public ListInstanceIdsResult listInstanceIds(ListInstanceIdsQuery query) { + Helpers.throwIfArgumentNull(query, "query"); ListInstanceIdsRequest.Builder builder = ListInstanceIdsRequest.newBuilder(); Optional.ofNullable(query.getCompletedTimeFrom()).ifPresent(from -> builder.setCompletedTimeFrom(DataConverter.getTimestampFromInstant(from))); Optional.ofNullable(query.getCompletedTimeTo()).ifPresent(to -> builder.setCompletedTimeTo(DataConverter.getTimestampFromInstant(to))); @@ -313,6 +314,7 @@ public ListInstanceIdsResult listInstanceIds(ListInstanceIdsQuery query) { @Override public List getOrchestrationHistory(String instanceId) { + Helpers.throwIfArgumentNull(instanceId, "instanceId"); StreamInstanceHistoryRequest request = StreamInstanceHistoryRequest.newBuilder() .setInstanceId(instanceId) .build(); From fe65bf49bec543e7e2e631984e4544e13cbae15e Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Thu, 2 Jul 2026 12:43:24 -0700 Subject: [PATCH 05/12] defensively copy tags in OrchestrationState --- .../com/microsoft/durabletask/history/OrchestrationState.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/main/java/com/microsoft/durabletask/history/OrchestrationState.java b/client/src/main/java/com/microsoft/durabletask/history/OrchestrationState.java index 79d32c34..40fcd78b 100644 --- a/client/src/main/java/com/microsoft/durabletask/history/OrchestrationState.java +++ b/client/src/main/java/com/microsoft/durabletask/history/OrchestrationState.java @@ -8,6 +8,7 @@ import javax.annotation.Nullable; import java.time.Instant; import java.util.Collections; +import java.util.HashMap; import java.util.Map; /** @@ -83,7 +84,7 @@ public OrchestrationState( this.failureDetails = failureDetails; this.executionId = executionId; this.parentInstanceId = parentInstanceId; - this.tags = tags == null ? null : Collections.unmodifiableMap(tags); + this.tags = tags == null ? null : Collections.unmodifiableMap(new HashMap<>(tags)); } /** @return the orchestration instance ID. */ From e7767d753f32abb13359db6e31d85a7644d92348 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Mon, 13 Jul 2026 17:33:37 -0700 Subject: [PATCH 06/12] addressed PR feedback --- .../durabletask/DurableTaskClient.java | 12 +- .../durabletask/ListInstanceIdsQuery.java | 6 +- .../HistoryEventConverterTest.java | 382 ++++++++++++++++++ .../durabletask/ListInstanceIdsQueryTest.java | 14 + 4 files changed, 411 insertions(+), 3 deletions(-) diff --git a/client/src/main/java/com/microsoft/durabletask/DurableTaskClient.java b/client/src/main/java/com/microsoft/durabletask/DurableTaskClient.java index 5c1a4672..e3ad5645 100644 --- a/client/src/main/java/com/microsoft/durabletask/DurableTaskClient.java +++ b/client/src/main/java/com/microsoft/durabletask/DurableTaskClient.java @@ -240,8 +240,12 @@ public abstract OrchestrationMetadata waitForInstanceCompletion( * * @param query filter criteria: completion-time window, terminal runtime statuses, page size, and pagination cursor * @return a page of matching instance IDs and a cursor for the next page + * @throws UnsupportedOperationException if the current client implementation does not support listing instance IDs */ - public abstract ListInstanceIdsResult listInstanceIds(ListInstanceIdsQuery query); + public ListInstanceIdsResult listInstanceIds(ListInstanceIdsQuery query) { + throw new UnsupportedOperationException( + "Listing instance IDs is not supported by this client implementation."); + } /** * Gets the full history of an orchestration instance as an ordered list of {@link HistoryEvent} objects. @@ -251,8 +255,12 @@ public abstract OrchestrationMetadata waitForInstanceCompletion( * * @param instanceId the unique ID of the orchestration instance whose history to fetch * @return the instance's history events in order; empty if the instance has no history + * @throws UnsupportedOperationException if the current client implementation does not support history retrieval */ - public abstract List getOrchestrationHistory(String instanceId); + public List getOrchestrationHistory(String instanceId) { + throw new UnsupportedOperationException( + "Retrieving orchestration history is not supported by this client implementation."); + } /** * Initializes the target task hub data store. diff --git a/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java b/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java index 15a08a1c..2834a246 100644 --- a/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java +++ b/client/src/main/java/com/microsoft/durabletask/ListInstanceIdsQuery.java @@ -68,10 +68,14 @@ public ListInstanceIdsQuery setCompletedTimeTo(@Nullable Instant completedTimeTo * 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 + * @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; } diff --git a/client/src/test/java/com/microsoft/durabletask/HistoryEventConverterTest.java b/client/src/test/java/com/microsoft/durabletask/HistoryEventConverterTest.java index 1922a735..e1a86a88 100644 --- a/client/src/test/java/com/microsoft/durabletask/HistoryEventConverterTest.java +++ b/client/src/test/java/com/microsoft/durabletask/HistoryEventConverterTest.java @@ -5,26 +5,48 @@ 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 @@ -217,6 +239,366 @@ void convertsHistoryStateExposesFullOrchestrationState() { 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(); diff --git a/client/src/test/java/com/microsoft/durabletask/ListInstanceIdsQueryTest.java b/client/src/test/java/com/microsoft/durabletask/ListInstanceIdsQueryTest.java index bd942ba7..76902522 100644 --- a/client/src/test/java/com/microsoft/durabletask/ListInstanceIdsQueryTest.java +++ b/client/src/test/java/com/microsoft/durabletask/ListInstanceIdsQueryTest.java @@ -10,6 +10,7 @@ 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; /** @@ -41,4 +42,17 @@ void setRuntimeStatusList_copiesInput_soExternalMutationDoesNotAffectQuery() { 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)); + } } From b808a8deb3e11401d524675efc5f2a451ddc7769 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Tue, 30 Jun 2026 16:42:25 -0700 Subject: [PATCH 07/12] initial commit --- .github/workflows/build-validation.yml | 8 + exporthistory/README.md | 99 ++++++ exporthistory/build.gradle | 178 +++++++++++ .../exporthistory/BlobExportWriter.java | 116 +++++++ .../CommitCheckpointRequest.java | 83 +++++ ...ExecuteExportJobOperationOrchestrator.java | 27 ++ .../exporthistory/ExportBlobNaming.java | 71 +++++ .../exporthistory/ExportCheckpoint.java | 44 +++ .../exporthistory/ExportDestination.java | 58 ++++ .../exporthistory/ExportFailure.java | 93 ++++++ .../exporthistory/ExportFilter.java | 85 ++++++ .../exporthistory/ExportFormat.java | 75 +++++ .../exporthistory/ExportFormatKind.java | 16 + .../exporthistory/ExportHistoryClient.java | 115 +++++++ .../ExportHistoryClientExtensions.java | 51 ++++ .../exporthistory/ExportHistoryConstants.java | 30 ++ .../exporthistory/ExportHistoryJobClient.java | 127 ++++++++ .../ExportHistoryStorageOptions.java | 172 +++++++++++ .../ExportHistoryWorkerExtensions.java | 94 ++++++ .../ExportInstanceHistoryActivity.java | 81 +++++ .../durabletask/exporthistory/ExportJob.java | 206 +++++++++++++ .../ExportJobClientValidationException.java | 32 ++ .../exporthistory/ExportJobConfiguration.java | 134 +++++++++ .../ExportJobCreationOptions.java | 245 +++++++++++++++ .../exporthistory/ExportJobDescription.java | 214 +++++++++++++ .../ExportJobInvalidTransitionException.java | 59 ++++ .../ExportJobNotFoundException.java | 30 ++ .../ExportJobOperationRequest.java | 80 +++++ .../exporthistory/ExportJobOrchestrator.java | 283 ++++++++++++++++++ .../exporthistory/ExportJobQuery.java | 130 ++++++++ .../exporthistory/ExportJobQueryResult.java | 39 +++ .../exporthistory/ExportJobRunRequest.java | 60 ++++ .../exporthistory/ExportJobState.java | 176 +++++++++++ .../exporthistory/ExportJobStatus.java | 22 ++ .../exporthistory/ExportJobTransitions.java | 63 ++++ .../durabletask/exporthistory/ExportMode.java | 16 + .../exporthistory/ExportRequest.java | 72 +++++ .../exporthistory/ExportResult.java | 109 +++++++ .../exporthistory/HistoryEventSerializer.java | 86 ++++++ .../exporthistory/InstancePage.java | 61 ++++ .../ListTerminalInstancesActivity.java | 51 ++++ .../ListTerminalInstancesRequest.java | 121 ++++++++ .../exporthistory/package-info.java | 38 +++ .../exporthistory/ExportBlobNamingTest.java | 56 ++++ .../ExportHistoryIntegrationTest.java | 205 +++++++++++++ .../ExportJobCreationOptionsTest.java | 145 +++++++++ .../ExportJobDescriptionTest.java | 74 +++++ .../ExportJobOrchestratorTest.java | 67 +++++ .../ExportJobTransitionsTest.java | 68 +++++ .../HistoryEventSerializerTest.java | 82 +++++ samples/build.gradle | 6 + .../samples/HistoryExportSample.java | 157 ++++++++++ settings.gradle | 1 + 53 files changed, 4811 insertions(+) create mode 100644 exporthistory/README.md create mode 100644 exporthistory/build.gradle create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportCheckpoint.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportDestination.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFilter.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormat.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormatKind.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClient.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClientExtensions.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobClientValidationException.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobConfiguration.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobDescription.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobInvalidTransitionException.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobNotFoundException.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQuery.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQueryResult.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobRunRequest.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobStatus.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportMode.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportRequest.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportResult.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/InstancePage.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesRequest.java create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/package-info.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryIntegrationTest.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptionsTest.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobDescriptionTest.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestratorTest.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTransitionsTest.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerTest.java create mode 100644 samples/src/main/java/io/durabletask/samples/HistoryExportSample.java diff --git a/.github/workflows/build-validation.yml b/.github/workflows/build-validation.yml index 32861877..1982b990 100644 --- a/.github/workflows/build-validation.yml +++ b/.github/workflows/build-validation.yml @@ -142,6 +142,14 @@ jobs: name: Integration test report path: client/build/reports/tests/integrationTest + - name: Archive export history integration test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: Export history integration test report + path: exporthistory/build/reports/tests/integrationTest + if-no-files-found: ignore + - name: Upload JAR output uses: actions/upload-artifact@v4 with: diff --git a/exporthistory/README.md b/exporthistory/README.md new file mode 100644 index 00000000..1cfb6399 --- /dev/null +++ b/exporthistory/README.md @@ -0,0 +1,99 @@ +# 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. + +## 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. **Export format** — history is serialized from the structured `com.microsoft.durabletask.history` domain model + (camelCase, null fields omitted, one event per line for JSONL). Byte-level parity with .NET's + protobuf-`HistoryEvent`-JSON output is an open item. + +## 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). + +## 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..486cff9f --- /dev/null +++ b/exporthistory/build.gradle @@ -0,0 +1,178 @@ +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' +def jacksonVersion = '2.18.3' + +// 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}" + + // Jackson — serialize the history domain model to JSONL/JSON for export. + implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jacksonVersion}" + + // 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..68b1421b --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java @@ -0,0 +1,116 @@ +// 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. Mirrors the upload behavior of the .NET {@code ExportInstanceHistoryActivity}. + */ +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) { + 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..ca8101ef --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java @@ -0,0 +1,83 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.CommitCheckpointRequest}. When + * {@link #getCheckpoint()} is non-null the cursor moves forward (successful batch); when {@code null} the cursor is + * retained (failed batch eligible for retry). + */ +public 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..0a54bb76 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +import com.microsoft.durabletask.TaskOrchestration; +import com.microsoft.durabletask.TaskOrchestrationContext; + +/** + * 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. Mirrors the .NET {@code ExecuteExportJobOperationOrchestrator}. + */ +public 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); + } +} 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..ac068c21 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java @@ -0,0 +1,71 @@ +// 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.format.DateTimeFormatter; + +/** + * Computes export blob names and paths. The blob name is a SHA-256 hash of + * {@code "|"} plus the format-specific extension, mirroring the .NET + * {@code ExportInstanceHistoryActivity} naming scheme. + */ +final class ExportBlobNaming { + + 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 = DateTimeFormatter.ISO_INSTANT.format(completedTimestamp) + "|" + instanceId; + return sha256Hex(hashInput) + "." + HistoryEventSerializer.fileExtension(format); + } + + /** + * 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..de4e8e44 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportCheckpoint.java @@ -0,0 +1,44 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportCheckpoint}. 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..3e03f1ad --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportDestination.java @@ -0,0 +1,58 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportDestination}. + */ +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..3c4401d0 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java @@ -0,0 +1,93 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportFailure}. + */ +public 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..53578d4b --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFilter.java @@ -0,0 +1,85 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportFilter}. + */ +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..71bf8d1a --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormat.java @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +import java.util.Objects; + +/** + * Export format settings. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportFormat} record. 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..f575734a --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormatKind.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +/** + * The kind of export format. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportFormatKind} source of truth. + */ +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..192c0513 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClient.java @@ -0,0 +1,115 @@ +// 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}. Mirrors the .NET + * {@code ExportHistoryClient} / {@code DefaultExportHistoryClient}. + */ +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..56b0cd6e --- /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. Mirrors the .NET client extension. + */ +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..296d3955 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +/** + * Constants used throughout the export history functionality. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportHistoryConstants} source of truth. + */ +public 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..140cc33b --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java @@ -0,0 +1,127 @@ +// 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; + +/** + * Client for managing a single export job via entity operations routed through + * {@link ExecuteExportJobOperationOrchestrator}. + *

+ * Mirrors the .NET {@code ExportHistoryJobClient} / {@code DefaultExportHistoryJobClient}. + */ +public final class ExportHistoryJobClient { + + private static final Duration OPERATION_TIMEOUT = Duration.ofSeconds(60); + + 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(); + + 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); + if (metadata == null) { + throw new ExportJobNotFoundException(this.jobId); + } + return ExportJobDescription.fromState(this.jobId, metadata.getState()); + } + + /** + * Deletes the export job entity. The export orchestrator self-exits on its next cycle once it observes the job + * is gone. + */ + public void delete() { + ExportJobOperationRequest request = new ExportJobOperationRequest( + this.entityId, ExportJobTransitions.OP_DELETE, null); + scheduleAndWait(request); + } + + 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..031a46f8 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java @@ -0,0 +1,172 @@ +// 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, mirroring the + * {@code azure-blob-payloads} add-on. Either {@link #setConnectionString(String)} or both + * {@link #setAccountUri(URI)} and {@link #setCredential(TokenCredential)} must be set before use. + *

+ * The .NET source of truth ({@code Microsoft.DurableTask.ExportHistory.ExportHistoryStorageOptions}) currently + * exposes connection-string auth only; the Java add-on additionally supports identity-based auth for parity with + * the existing {@code azure-blob-payloads} module. + * + *

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; + private ExportFormat format = ExportFormat.getDefault(); + + /** + * 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; + } + + /** + * Gets the export format. Defaults to {@link ExportFormat#getDefault()} (JSONL + gzip). + * + * @return the export format + */ + public ExportFormat getFormat() { + return this.format; + } + + /** + * Sets the export format. + * + * @param format the export format + * @return this options object + */ + public ExportHistoryStorageOptions setFormat(ExportFormat format) { + this.format = format; + 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..a1a17667 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java @@ -0,0 +1,94 @@ +// 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. + *

+ * Unlike the .NET worker extension (which relies on dependency injection for the {@link DurableTaskClient}), the + * Java activities require an explicit client 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.class); + + 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..d1116731 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java @@ -0,0 +1,81 @@ +// 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 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. + *

+ * Mirrors the .NET {@code ExportInstanceHistoryActivity}: 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. + */ +public 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, true); + 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()); + } + } +} 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..4649d013 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java @@ -0,0 +1,206 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJob} entity. Operations are dispatched by method + * name (case-insensitive): {@code Create}, {@code Get}, {@code Run}, {@code CommitCheckpoint}, + * {@code MarkAsCompleted}, {@code MarkAsFailed}, {@code Delete}. + */ +public 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()); + ExportFilter filter = new ExportFilter( + creationOptions.getCompletedTimeFrom(), + creationOptions.getCompletedTimeTo(), + statuses); + ExportJobConfiguration config = new ExportJobConfiguration( + creationOptions.getMode(), + filter, + creationOptions.getDestination(), + creationOptions.getFormat(), + creationOptions.getMaxInstancesPerBatch()); + + Instant now = Instant.now(); + 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..d6a43e5a --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobClientValidationException.java @@ -0,0 +1,32 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobClientValidationException}. + */ +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..e9c4792f --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobConfiguration.java @@ -0,0 +1,134 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobConfiguration}. + */ +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..27d4b365 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java @@ -0,0 +1,245 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobCreationOptions}. 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) { + 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, mirroring the .NET constructor checks. 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..6fe61c05 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobDescription.java @@ -0,0 +1,214 @@ +// 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}. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobDescription}. + */ +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..9a3ee31c --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobInvalidTransitionException.java @@ -0,0 +1,59 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobInvalidTransitionException}. + */ +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..0ae4140a --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobNotFoundException.java @@ -0,0 +1,30 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobNotFoundException}. + */ +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/ExportJobOperationRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java new file mode 100644 index 00000000..021d6c18 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +import com.microsoft.durabletask.EntityInstanceId; + +import javax.annotation.Nullable; + +/** + * 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. + *

+ * Mirrors the .NET {@code ExportJobOperationRequest} record. + */ +public 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/ExportJobOrchestrator.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java new file mode 100644 index 00000000..161997cd --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java @@ -0,0 +1,283 @@ +// 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}. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobOrchestrator}. + */ +public 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); + } + } +} 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..b76a2df4 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQuery.java @@ -0,0 +1,130 @@ +// 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)}. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.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/ExportJobRunRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobRunRequest.java new file mode 100644 index 00000000..f3ca9ff4 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobRunRequest.java @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +import com.microsoft.durabletask.EntityInstanceId; + +/** + * Input to the {@link ExportJobOrchestrator} identifying the job entity and the number of processed cycles + * (used to bound work before {@code continueAsNew}). + *

+ * Mirrors the .NET {@code ExportJobRunRequest} record. + */ +public 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/ExportJobState.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java new file mode 100644 index 00000000..60a7e4d5 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java @@ -0,0 +1,176 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobState}. + */ +public 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..d7eed690 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobStatus.java @@ -0,0 +1,22 @@ +// 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. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobStatus} source of truth. + */ +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..f751a078 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +/** + * Valid state-transition rules for export jobs. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobTransitions}. The operation-name constants + * match the {@link ExportJob} entity method names (case-insensitive dispatch). + */ +public 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..196e14c5 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportMode.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +/** + * Export job modes. + *

+ * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportMode} source of truth. + */ +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/ExportRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportRequest.java new file mode 100644 index 00000000..65b3f8d4 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportRequest.java @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +/** + * Input to {@link ExportInstanceHistoryActivity}: the instance to export plus the destination and format. + */ +public 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; + } +} diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportResult.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportResult.java new file mode 100644 index 00000000..5f296f4b --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportResult.java @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +import javax.annotation.Nullable; + +/** + * Output of {@link ExportInstanceHistoryActivity}: whether a single instance's history export succeeded, and the + * blob name written (or the error on failure). + */ +public 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/HistoryEventSerializer.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java new file mode 100644 index 00000000..716ac2d6 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +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.microsoft.durabletask.history.HistoryEvent; + +import java.util.List; + +/** + * Serializes the {@code com.microsoft.durabletask.history} domain model to the export wire format. + *

+ * Mirrors the .NET {@code ExportInstanceHistoryActivity} serialization: camelCase property names, null fields + * omitted, ISO-8601 timestamps, and one history event per line for {@link ExportFormatKind#JSONL} (gzip applied by + * the blob writer) or a JSON array for {@link ExportFormatKind#JSON}. + *

+ * Note: like the .NET implementation, events are serialized by their concrete runtime type without a type + * discriminator field. Byte-level parity with .NET's output is an open item tracked in the module design. + */ +final class HistoryEventSerializer { + + private static final ObjectMapper MAPPER = JsonMapper.builder() + .findAndAddModules() + .propertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE) + .serializationInclusion(com.fasterxml.jackson.annotation.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 com.fasterxml.jackson.core.JsonProcessingException if serialization fails + */ + static String serialize(List historyEvents, ExportFormat format) + throws com.fasterxml.jackson.core.JsonProcessingException { + if (format.getKind() == ExportFormatKind.JSON) { + return MAPPER.writeValueAsString(historyEvents); + } + // JSONL: one event per line, serialized by concrete runtime type. + StringBuilder sb = new StringBuilder(); + for (HistoryEvent event : historyEvents) { + sb.append(MAPPER.writeValueAsString(event)); + sb.append('\n'); + } + 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"; + } +} diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/InstancePage.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/InstancePage.java new file mode 100644 index 00000000..1307834a --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/InstancePage.java @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask.exporthistory; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; + +/** + * Output of {@link ListTerminalInstancesActivity}: a page of terminal instance IDs plus the checkpoint to advance + * to once this page has been exported. + */ +public 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/ListTerminalInstancesActivity.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java new file mode 100644 index 00000000..b470eea5 --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.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.ListInstanceIdsQuery; +import com.microsoft.durabletask.ListInstanceIdsResult; +import com.microsoft.durabletask.TaskActivity; +import com.microsoft.durabletask.TaskActivityContext; + +import java.util.ArrayList; + +/** + * 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. + *

+ * Mirrors the .NET {@code ListTerminalInstancesActivity}. + */ +public 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); + return new InstancePage( + new ArrayList<>(result.getInstanceIds()), + new ExportCheckpoint(result.getContinuationToken())); + } +} diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesRequest.java new file mode 100644 index 00000000..5446a60b --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesRequest.java @@ -0,0 +1,121 @@ +// 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; + +/** + * Input to {@link ListTerminalInstancesActivity}: a completion-time window, terminal status filter, pagination + * cursor, and batch size. + */ +public 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; + } +} 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..666642ce --- /dev/null +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/package-info.java @@ -0,0 +1,38 @@ +// 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. + *

+ * This module is at parity with the .NET {@code Microsoft.DurableTask.ExportHistory} (preview) feature: a durable, + * 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. + * + *

Scaffold status

+ * This package currently contains the stable configuration/value surface only. The remaining components below are + * the implementation work for PR 2 and should be added next, mirroring the .NET source of truth under + * {@code durabletask-dotnet/src/ExportHistory}: + *
    + *
  • {@code ExportJob} entity — config, status, checkpoint cursor, progress counters; signals a run on create.
  • + *
  • {@code ExportJobOrchestrator} — pages terminal instances, fans out export activities, commits checkpoints, + * handles BATCH vs CONTINUOUS, retries with backoff, and {@code continueAsNew}s periodically.
  • + *
  • {@code ListTerminalInstancesActivity} — calls the client {@code listInstanceIds} wrapper.
  • + *
  • {@code ExportInstanceHistoryActivity} — calls the client {@code getOrchestrationHistory} wrapper, serializes + * the {@code com.microsoft.durabletask.history} domain model to gzipped JSONL, and uploads to blob.
  • + *
  • {@code ExportHistoryClient} / {@code ExportHistoryJobClient} — {@code createJob}/{@code getJob}/ + * {@code listJobs}/{@code getJobClient}, backed by entity signals/reads.
  • + *
  • {@code ExportHistoryWorkerExtensions.useExportHistory(...)} and + * {@code ExportHistoryClientExtensions.useExportHistory(...)} — registration entry points, mirroring the + * {@code azure-blob-payloads} add-on.
  • + *
  • Models/exceptions — {@code ExportCheckpoint}, {@code ExportFailure}, {@code ExportJobState}, + * {@code ExportJobDescription}, {@code ExportJobQuery}, {@code ExportJobNotFoundException}, etc.
  • + *
+ * + *

Open design item (carried from PR 1)

+ * PR 1's {@code getOrchestrationHistory} returns the structured {@code history} domain model rather than + * protobuf-JSON strings. Byte-level export-format parity with .NET's protobuf-{@code HistoryEvent}-JSON output is an + * open decision: either serialize the domain model to the same shape or reconstruct proto-JSON in the activity. + * + * @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..5a8f70dd --- /dev/null +++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java @@ -0,0 +1,56 @@ +// 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.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")); + } +} 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/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/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/HistoryEventSerializerTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerTest.java new file mode 100644 index 00000000..b2753921 --- /dev/null +++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerTest.java @@ -0,0 +1,82 @@ +// 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 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"))); + } +} 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

+ *
    + *
  1. DTS emulator: {@code docker run -d -p 8080:8080 mcr.microsoft.com/dts/dts-emulator:latest}
  2. + *
  3. Azurite: {@code docker run -d -p 10000:10000 mcr.microsoft.com/azure-storage/azurite azurite-blob --blobHost 0.0.0.0}
  4. + *
+ * + *

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" From a9ca50d40b907dc283cce03445b067e40d30f39a Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Wed, 1 Jul 2026 16:20:51 -0700 Subject: [PATCH 08/12] add more fixes for parity --- exporthistory/README.md | 42 +- exporthistory/build.gradle | 5 - .../exporthistory/BlobExportWriter.java | 2 +- .../CommitCheckpointRequest.java | 5 +- ...ExecuteExportJobOperationOrchestrator.java | 2 +- .../exporthistory/ExportBlobNaming.java | 31 +- .../exporthistory/ExportCheckpoint.java | 3 +- .../exporthistory/ExportDestination.java | 2 - .../exporthistory/ExportFailure.java | 2 - .../exporthistory/ExportFilter.java | 2 - .../exporthistory/ExportFormat.java | 5 +- .../exporthistory/ExportFormatKind.java | 2 - .../exporthistory/ExportHistoryClient.java | 3 +- .../ExportHistoryClientExtensions.java | 2 +- .../exporthistory/ExportHistoryConstants.java | 2 - .../exporthistory/ExportHistoryJobClient.java | 2 - .../ExportHistoryStorageOptions.java | 10 +- .../ExportHistoryWorkerExtensions.java | 5 +- .../ExportInstanceHistoryActivity.java | 7 +- .../durabletask/exporthistory/ExportJob.java | 5 +- .../ExportJobClientValidationException.java | 2 - .../exporthistory/ExportJobConfiguration.java | 2 - .../ExportJobCreationOptions.java | 5 +- .../exporthistory/ExportJobDescription.java | 2 - .../ExportJobInvalidTransitionException.java | 2 - .../ExportJobNotFoundException.java | 2 - .../ExportJobOperationRequest.java | 2 - .../exporthistory/ExportJobOrchestrator.java | 2 - .../exporthistory/ExportJobQuery.java | 2 - .../exporthistory/ExportJobRunRequest.java | 2 - .../exporthistory/ExportJobState.java | 2 - .../exporthistory/ExportJobStatus.java | 2 - .../exporthistory/ExportJobTransitions.java | 6 +- .../durabletask/exporthistory/ExportMode.java | 2 - .../exporthistory/HistoryEventSerializer.java | 373 +++++++++++++++++- .../exporthistory/HtmlSafeJsonEscapes.java | 49 +++ .../ListTerminalInstancesActivity.java | 2 - .../exporthistory/package-info.java | 45 +-- .../exporthistory/ExportBlobNamingTest.java | 26 ++ .../HistoryEventSerializerParityTest.java | 161 ++++++++ .../golden/reference-history-events.jsonl | 22 ++ 41 files changed, 723 insertions(+), 129 deletions(-) create mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HtmlSafeJsonEscapes.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerParityTest.java create mode 100644 exporthistory/src/test/resources/golden/reference-history-events.jsonl diff --git a/exporthistory/README.md b/exporthistory/README.md index 1cfb6399..ee0ef494 100644 --- a/exporthistory/README.md +++ b/exporthistory/README.md @@ -76,13 +76,29 @@ supplied, all three are exported. - **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. **Export format** — history is serialized from the structured `com.microsoft.durabletask.history` domain model - (camelCase, null fields omitted, one event per line for JSONL). Byte-level parity with .NET's - protobuf-`HistoryEvent`-JSON output is an open item. +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 @@ -90,6 +106,26 @@ The export feature relies on the `ListInstanceIds` and `StreamInstanceHistory` g 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): diff --git a/exporthistory/build.gradle b/exporthistory/build.gradle index 486cff9f..e24fa0c6 100644 --- a/exporthistory/build.gradle +++ b/exporthistory/build.gradle @@ -12,7 +12,6 @@ archivesBaseName = 'durabletask-exporthistory' def grpcVersion = '1.78.0' def azureCoreVersion = '1.57.1' def azureStorageBlobVersion = '12.29.1' -def jacksonVersion = '2.18.3' // 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/ @@ -34,10 +33,6 @@ dependencies { // Azure Storage Blobs — export destination for serialized history. implementation "com.azure:azure-storage-blob:${azureStorageBlobVersion}" - // Jackson — serialize the history domain model to JSONL/JSON for export. - implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" - implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jacksonVersion}" - // TokenCredential abstraction (from azure-core) — 'api' because // ExportHistoryStorageOptions exposes TokenCredential in its public API. api "com.azure:azure-core:${azureCoreVersion}" diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java index 68b1421b..5330b5e6 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java @@ -23,7 +23,7 @@ *

* 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. Mirrors the upload behavior of the .NET {@code ExportInstanceHistoryActivity}. + * use. */ final class BlobExportWriter { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java index ca8101ef..8e82a074 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java @@ -8,9 +8,8 @@ /** * Request to commit a checkpoint with progress updates and optional failures. *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.CommitCheckpointRequest}. When - * {@link #getCheckpoint()} is non-null the cursor moves forward (successful batch); when {@code null} the cursor is - * retained (failed batch eligible for retry). + * When {@link #getCheckpoint()} is non-null the cursor moves forward (successful batch); when {@code null} the + * cursor is retained (failed batch eligible for retry). */ public final class CommitCheckpointRequest { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java index 0a54bb76..f8fca728 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java @@ -9,7 +9,7 @@ * 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. Mirrors the .NET {@code ExecuteExportJobOperationOrchestrator}. + * surface validation errors. */ public final class ExecuteExportJobOperationOrchestrator implements TaskOrchestration { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java index ac068c21..0829ab3a 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java @@ -6,15 +6,22 @@ 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; /** - * Computes export blob names and paths. The blob name is a SHA-256 hash of - * {@code "|"} plus the format-specific extension, mirroring the .NET - * {@code ExportInstanceHistoryActivity} naming scheme. + * 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() { } @@ -27,10 +34,26 @@ private ExportBlobNaming() { * @return the blob file name, e.g. {@code ".jsonl.gz"} */ static String blobFileName(Instant completedTimestamp, String instanceId, ExportFormat format) { - String hashInput = DateTimeFormatter.ISO_INSTANT.format(completedTimestamp) + "|" + instanceId; + 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("%07d", ticks) + "+00:00"; + } + /** * Combines an optional prefix with a blob file name. * diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportCheckpoint.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportCheckpoint.java index de4e8e44..ed1af603 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportCheckpoint.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportCheckpoint.java @@ -7,8 +7,7 @@ /** * Checkpoint information used to resume an export. *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportCheckpoint}. The - * {@code lastInstanceKey} is the pagination cursor returned by the client {@code listInstanceIds} wrapper. + * The {@code lastInstanceKey} is the pagination cursor returned by the client {@code listInstanceIds} wrapper. */ public final class ExportCheckpoint { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportDestination.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportDestination.java index 3e03f1ad..0922bf27 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportDestination.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportDestination.java @@ -6,8 +6,6 @@ /** * Export destination settings for Azure Blob Storage. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportDestination}. */ public final class ExportDestination { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java index 3c4401d0..354c5c01 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java @@ -6,8 +6,6 @@ /** * Failure of a specific instance export. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportFailure}. */ public final class ExportFailure { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFilter.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFilter.java index 53578d4b..7df1b373 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFilter.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFilter.java @@ -10,8 +10,6 @@ /** * Filter criteria for selecting orchestration instances to export. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportFilter}. */ public final class ExportFilter { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormat.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormat.java index 71bf8d1a..683ba005 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormat.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormat.java @@ -5,10 +5,7 @@ import java.util.Objects; /** - * Export format settings. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportFormat} record. The default is - * {@link ExportFormatKind#JSONL} with schema version {@code "1.0"}. + * Export format settings. The default is {@link ExportFormatKind#JSONL} with schema version {@code "1.0"}. */ public final class ExportFormat { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormatKind.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormatKind.java index f575734a..1488eae3 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormatKind.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFormatKind.java @@ -4,8 +4,6 @@ /** * The kind of export format. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportFormatKind} source of truth. */ public enum ExportFormatKind { /** JSONL format (one history event per line, compressed with gzip). */ diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClient.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClient.java index 192c0513..5819a8af 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClient.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClient.java @@ -15,8 +15,7 @@ /** * Convenience client for creating, reading, and listing export jobs, backed by entity operations and reads. *

- * Obtain an instance via {@link ExportHistoryClientExtensions#useExportHistory}. Mirrors the .NET - * {@code ExportHistoryClient} / {@code DefaultExportHistoryClient}. + * Obtain an instance via {@link ExportHistoryClientExtensions#useExportHistory}. */ public final class ExportHistoryClient { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClientExtensions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClientExtensions.java index 56b0cd6e..7a4680bf 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClientExtensions.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryClientExtensions.java @@ -11,7 +11,7 @@ * 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. Mirrors the .NET client extension. + * supplied blob storage destination. */ public final class ExportHistoryClientExtensions { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java index 296d3955..cd9f1353 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java @@ -4,8 +4,6 @@ /** * Constants used throughout the export history functionality. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportHistoryConstants} source of truth. */ public final class ExportHistoryConstants { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java index 140cc33b..6992b063 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java @@ -16,8 +16,6 @@ /** * Client for managing a single export job via entity operations routed through * {@link ExecuteExportJobOperationOrchestrator}. - *

- * Mirrors the .NET {@code ExportHistoryJobClient} / {@code DefaultExportHistoryJobClient}. */ public final class ExportHistoryJobClient { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java index 031a46f8..b1c7b0f2 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java @@ -10,13 +10,9 @@ /** * Configuration for the Azure Blob Storage destination of an export history job. *

- * Supports both connection-string and identity-based ({@link TokenCredential}) authentication, mirroring the - * {@code azure-blob-payloads} add-on. Either {@link #setConnectionString(String)} or both - * {@link #setAccountUri(URI)} and {@link #setCredential(TokenCredential)} must be set before use. - *

- * The .NET source of truth ({@code Microsoft.DurableTask.ExportHistory.ExportHistoryStorageOptions}) currently - * exposes connection-string auth only; the Java add-on additionally supports identity-based auth for parity with - * the existing {@code azure-blob-payloads} module. + * 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
diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java
index a1a17667..72480b40 100644
--- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java
+++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java
@@ -18,9 +18,8 @@
  * {@link ExecuteExportJobOperationOrchestrator} orchestrators, and the {@link ListTerminalInstancesActivity} and
  * {@link ExportInstanceHistoryActivity} activities.
  * 

- * Unlike the .NET worker extension (which relies on dependency injection for the {@link DurableTaskClient}), the - * Java activities require an explicit client to call {@code listInstanceIds} / {@code getOrchestrationHistory} - * against the same backend the worker targets. + * 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 { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java index d1116731..1e0bde43 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java @@ -16,10 +16,9 @@ /** * Activity that exports a single orchestration instance's history to the configured blob destination. *

- * Mirrors the .NET {@code ExportInstanceHistoryActivity}: 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. + * 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. */ public final class ExportInstanceHistoryActivity implements TaskActivity { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java index 4649d013..1ab04186 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java @@ -16,9 +16,8 @@ /** * Durable entity that manages a history export job: lifecycle, configuration, checkpoint, and progress. *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJob} entity. Operations are dispatched by method - * name (case-insensitive): {@code Create}, {@code Get}, {@code Run}, {@code CommitCheckpoint}, - * {@code MarkAsCompleted}, {@code MarkAsFailed}, {@code Delete}. + * Operations are dispatched by method name (case-insensitive): {@code Create}, {@code Get}, {@code Run}, + * {@code CommitCheckpoint}, {@code MarkAsCompleted}, {@code MarkAsFailed}, {@code Delete}. */ public final class ExportJob extends AbstractTaskEntity { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobClientValidationException.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobClientValidationException.java index d6a43e5a..a7f4f106 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobClientValidationException.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobClientValidationException.java @@ -4,8 +4,6 @@ /** * Thrown when export job creation options or client arguments fail validation. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobClientValidationException}. */ public final class ExportJobClientValidationException extends RuntimeException { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobConfiguration.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobConfiguration.java index e9c4792f..097f6a00 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobConfiguration.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobConfiguration.java @@ -4,8 +4,6 @@ /** * Configuration for an export job, persisted in the {@link ExportJobState} entity state. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobConfiguration}. */ public final class ExportJobConfiguration { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java index 27d4b365..2bca3221 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java @@ -16,8 +16,7 @@ /** * Configuration for creating an export job. *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobCreationOptions}. Export supports - * terminal orchestration statuses only ({@link OrchestrationRuntimeStatus#COMPLETED}, + * 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. * @@ -214,7 +213,7 @@ public ExportJobCreationOptions setDestination(@Nullable ExportDestination desti } /** - * Validates mode-specific completion-window rules, mirroring the .NET constructor checks. Intended to be called + * 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 diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobDescription.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobDescription.java index 6fe61c05..3f7d16d2 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobDescription.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobDescription.java @@ -7,8 +7,6 @@ /** * Client-facing description of an export job, projected from the entity {@link ExportJobState}. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobDescription}. */ public final class ExportJobDescription { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobInvalidTransitionException.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobInvalidTransitionException.java index 9a3ee31c..8550be59 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobInvalidTransitionException.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobInvalidTransitionException.java @@ -4,8 +4,6 @@ /** * Thrown when an export job operation attempts an invalid status transition. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobInvalidTransitionException}. */ public final class ExportJobInvalidTransitionException extends RuntimeException { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobNotFoundException.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobNotFoundException.java index 0ae4140a..8cecefb2 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobNotFoundException.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobNotFoundException.java @@ -4,8 +4,6 @@ /** * Thrown when an export job cannot be found. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobNotFoundException}. */ public final class ExportJobNotFoundException extends RuntimeException { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java index 021d6c18..91e26bb8 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java @@ -9,8 +9,6 @@ /** * 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. - *

- * Mirrors the .NET {@code ExportJobOperationRequest} record. */ public final class ExportJobOperationRequest { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java index 161997cd..07083876 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java @@ -23,8 +23,6 @@ * 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}. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobOrchestrator}. */ public final class ExportJobOrchestrator implements TaskOrchestration { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQuery.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQuery.java index b76a2df4..e888f3e6 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQuery.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobQuery.java @@ -7,8 +7,6 @@ /** * Query parameters for filtering export history jobs via {@link ExportHistoryClient#listJobs(ExportJobQuery)}. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobQuery}. */ public final class ExportJobQuery { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobRunRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobRunRequest.java index f3ca9ff4..4a5e7474 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobRunRequest.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobRunRequest.java @@ -7,8 +7,6 @@ /** * Input to the {@link ExportJobOrchestrator} identifying the job entity and the number of processed cycles * (used to bound work before {@code continueAsNew}). - *

- * Mirrors the .NET {@code ExportJobRunRequest} record. */ public final class ExportJobRunRequest { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java index 60a7e4d5..f8d4ed87 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java @@ -7,8 +7,6 @@ /** * Export job state stored in the {@link ExportJob} entity. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobState}. */ public final class ExportJobState { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobStatus.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobStatus.java index d7eed690..7931c2ba 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobStatus.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobStatus.java @@ -4,8 +4,6 @@ /** * Represents the current status of an export history job. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobStatus} source of truth. */ public enum ExportJobStatus { /** The export history job has been created but is not yet active. */ diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java index f751a078..9a421f2e 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java @@ -3,10 +3,8 @@ package com.microsoft.durabletask.exporthistory; /** - * Valid state-transition rules for export jobs. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportJobTransitions}. The operation-name constants - * match the {@link ExportJob} entity method names (case-insensitive dispatch). + * Valid state-transition rules for export jobs. The operation-name constants match the {@link ExportJob} entity + * method names (case-insensitive dispatch). */ public final class ExportJobTransitions { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportMode.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportMode.java index 196e14c5..c96399c0 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportMode.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportMode.java @@ -4,8 +4,6 @@ /** * Export job modes. - *

- * Mirrors the .NET {@code Microsoft.DurableTask.ExportHistory.ExportMode} source of truth. */ public enum ExportMode { /** Exports a fixed completion-time window and then completes. */ diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java index 716ac2d6..08c6e7bc 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java @@ -2,30 +2,84 @@ // 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.Map; /** * Serializes the {@code com.microsoft.durabletask.history} domain model to the export wire format. *

- * Mirrors the .NET {@code ExportInstanceHistoryActivity} serialization: camelCase property names, null fields - * omitted, ISO-8601 timestamps, and one history event per line for {@link ExportFormatKind#JSONL} (gzip applied by - * the blob writer) or a JSON array for {@link ExportFormatKind#JSON}. + * 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 strings escaped by {@link HtmlSafeJsonEscapes}. *

- * Note: like the .NET implementation, events are serialized by their concrete runtime type without a type - * discriminator field. Byte-level parity with .NET's output is an open item tracked in the module design. + * {@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. */ final class HistoryEventSerializer { - private static final ObjectMapper MAPPER = JsonMapper.builder() + 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(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + .serializationInclusion(JsonInclude.Include.NON_NULL) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build(); @@ -38,18 +92,31 @@ private HistoryEventSerializer() { * @param historyEvents the ordered history events * @param format the export format * @return the serialized content (JSONL text or JSON array text) - * @throws com.fasterxml.jackson.core.JsonProcessingException if serialization fails + * @throws JsonProcessingException if serialization of an entity event fails */ static String serialize(List historyEvents, ExportFormat format) - throws com.fasterxml.jackson.core.JsonProcessingException { - if (format.getKind() == ExportFormatKind.JSON) { - return MAPPER.writeValueAsString(historyEvents); - } - // JSONL: one event per line, serialized by concrete runtime type. + throws JsonProcessingException { StringBuilder sb = new StringBuilder(); - for (HistoryEvent event : historyEvents) { - sb.append(MAPPER.writeValueAsString(event)); - sb.append('\n'); + 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(); } @@ -83,4 +150,278 @@ static boolean isCompressed(ExportFormat format) { 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("%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 index b470eea5..d8edb404 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java @@ -13,8 +13,6 @@ /** * 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. - *

- * Mirrors the .NET {@code ListTerminalInstancesActivity}. */ public final class ListTerminalInstancesActivity implements TaskActivity { 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 index 666642ce..eaa1900d 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/package-info.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/package-info.java @@ -4,35 +4,30 @@ /** * Durable export of terminal orchestration history to Azure Blob Storage for the Durable Task Java SDK. *

- * This module is at parity with the .NET {@code Microsoft.DurableTask.ExportHistory} (preview) feature: a durable, - * 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. + * 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. * - *

Scaffold status

- * This package currently contains the stable configuration/value surface only. The remaining components below are - * the implementation work for PR 2 and should be added next, mirroring the .NET source of truth under - * {@code durabletask-dotnet/src/ExportHistory}: + *

Components

*
    - *
  • {@code ExportJob} entity — config, status, checkpoint cursor, progress counters; signals a run on create.
  • - *
  • {@code ExportJobOrchestrator} — pages terminal instances, fans out export activities, commits checkpoints, - * handles BATCH vs CONTINUOUS, retries with backoff, and {@code continueAsNew}s periodically.
  • - *
  • {@code ListTerminalInstancesActivity} — calls the client {@code listInstanceIds} wrapper.
  • - *
  • {@code ExportInstanceHistoryActivity} — calls the client {@code getOrchestrationHistory} wrapper, serializes - * the {@code com.microsoft.durabletask.history} domain model to gzipped JSONL, and uploads to blob.
  • - *
  • {@code ExportHistoryClient} / {@code ExportHistoryJobClient} — {@code createJob}/{@code getJob}/ - * {@code listJobs}/{@code getJobClient}, backed by entity signals/reads.
  • - *
  • {@code ExportHistoryWorkerExtensions.useExportHistory(...)} and - * {@code ExportHistoryClientExtensions.useExportHistory(...)} — registration entry points, mirroring the - * {@code azure-blob-payloads} add-on.
  • - *
  • Models/exceptions — {@code ExportCheckpoint}, {@code ExportFailure}, {@code ExportJobState}, - * {@code ExportJobDescription}, {@code ExportJobQuery}, {@code ExportJobNotFoundException}, etc.
  • + *
  • {@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.
  • *
* - *

Open design item (carried from PR 1)

- * PR 1's {@code getOrchestrationHistory} returns the structured {@code history} domain model rather than - * protobuf-JSON strings. Byte-level export-format parity with .NET's protobuf-{@code HistoryEvent}-JSON output is an - * open decision: either serialize the domain model to the same shape or reconstruct proto-JSON in the activity. - * * @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 index 5a8f70dd..8fed8cec 100644 --- a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java +++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Instant; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -53,4 +55,28 @@ void blobPath_handlesPrefix() { 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 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/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/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"} From a26b01bbb0d28dc2bdd8aeb1f4f7d90bfee292a3 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Thu, 2 Jul 2026 12:58:51 -0700 Subject: [PATCH 09/12] addressed PR feedback --- .../durabletask/exporthistory/BlobExportWriter.java | 6 ++++++ .../durabletask/exporthistory/ExportHistoryJobClient.java | 3 +++ .../exporthistory/ExportInstanceHistoryActivity.java | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java index 5330b5e6..eec1808d 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/BlobExportWriter.java @@ -88,6 +88,12 @@ final class BlobExportWriter { * @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(); diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java index 6992b063..52c0898c 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java @@ -59,6 +59,9 @@ public void create(ExportJobCreationOptions options) { 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); diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java index 1e0bde43..c6de0ad8 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java @@ -47,7 +47,7 @@ public Object run(TaskActivityContext ctx) { String instanceId = input.getInstanceId(); try { - OrchestrationMetadata metadata = this.client.getInstanceMetadata(instanceId, true); + OrchestrationMetadata metadata = this.client.getInstanceMetadata(instanceId, false); if (metadata == null || !metadata.isInstanceFound()) { return ExportResult.failure(instanceId, "Instance " + instanceId + " not found"); } From a7e173b0cb2d36257bdee41ff2b5cd8c240b5ba0 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Wed, 15 Jul 2026 17:03:58 -0700 Subject: [PATCH 10/12] Fix CONTINUOUS export defaulting completedTimeFrom to creation time --- .../durabletask/exporthistory/ExportJob.java | 12 +++- .../exporthistory/ExportJobTest.java | 68 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTest.java diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java index 1ab04186..5c766ff2 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java @@ -63,8 +63,17 @@ public void create(ExportJobCreationOptions creationOptions) { creationOptions.getRuntimeStatus() == null ? null : new ArrayList<>(creationOptions.getRuntimeStatus()); + + // Resolve the completion-time lower bound to the job creation instant when the caller omitted it. This + // mirrors the .NET SDK (completedTimeFrom ?? UtcNow) so a CONTINUOUS job tails only completions that occur + // after the job is created, instead of re-exporting every historical instance in the task hub. BATCH always + // supplies an explicit lower bound (enforced by ExportJobCreationOptions.validateForCreate()). + Instant now = Instant.now(); + Instant completedTimeFrom = creationOptions.getCompletedTimeFrom() != null + ? creationOptions.getCompletedTimeFrom() + : now; ExportFilter filter = new ExportFilter( - creationOptions.getCompletedTimeFrom(), + completedTimeFrom, creationOptions.getCompletedTimeTo(), statuses); ExportJobConfiguration config = new ExportJobConfiguration( @@ -74,7 +83,6 @@ public void create(ExportJobCreationOptions creationOptions) { creationOptions.getFormat(), creationOptions.getMaxInstancesPerBatch()); - Instant now = Instant.now(); this.state.setConfig(config); this.state.setStatus(ExportJobStatus.ACTIVE); this.state.setCreatedAt(now); 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); + } +} From f199c741815cb060ca249686fef91887b77e928c Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Fri, 17 Jul 2026 18:47:10 -0700 Subject: [PATCH 11/12] refined implementation and addressed feedback --- .../durabletask/AbstractTaskEntity.java | 8 + .../microsoft/durabletask/TaskEntityTest.java | 23 +++ .../CommitCheckpointRequest.java | 2 +- ...ExecuteExportJobOperationOrchestrator.java | 76 +++++++- .../exporthistory/ExportBlobNaming.java | 3 +- .../exporthistory/ExportFailure.java | 2 +- .../exporthistory/ExportHistoryConstants.java | 2 +- .../exporthistory/ExportHistoryJobClient.java | 31 ++- .../ExportHistoryStorageOptions.java | 21 --- .../ExportHistoryWorkerExtensions.java | 2 +- .../ExportInstanceHistoryActivity.java | 176 +++++++++++++++++- .../durabletask/exporthistory/ExportJob.java | 6 +- .../ExportJobOperationRequest.java | 78 -------- .../exporthistory/ExportJobOrchestrator.java | 55 +++++- .../exporthistory/ExportJobRunRequest.java | 58 ------ .../exporthistory/ExportJobState.java | 2 +- .../exporthistory/ExportJobTransitions.java | 2 +- .../exporthistory/ExportRequest.java | 72 ------- .../exporthistory/ExportResult.java | 109 ----------- .../exporthistory/HistoryEventSerializer.java | 3 +- .../exporthistory/InstancePage.java | 61 ------ .../ListTerminalInstancesActivity.java | 172 ++++++++++++++++- .../ListTerminalInstancesRequest.java | 121 ------------ .../exporthistory/ExportBlobNamingTest.java | 13 ++ .../ExportHistoryJobClientTest.java | 82 ++++++++ .../ExportHistoryStorageOptionsTest.java | 21 +++ .../ExportHistoryWorkerExtensionsTest.java | 42 +++++ .../exporthistory/ExportJobTest.java | 2 +- .../HistoryEventSerializerTest.java | 16 ++ 29 files changed, 718 insertions(+), 543 deletions(-) delete mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java delete mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobRunRequest.java delete mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportRequest.java delete mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportResult.java delete mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/InstancePage.java delete mode 100644 exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesRequest.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClientTest.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptionsTest.java create mode 100644 exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensionsTest.java diff --git a/client/src/main/java/com/microsoft/durabletask/AbstractTaskEntity.java b/client/src/main/java/com/microsoft/durabletask/AbstractTaskEntity.java index 3ffa1f84..80b13e84 100644 --- a/client/src/main/java/com/microsoft/durabletask/AbstractTaskEntity.java +++ b/client/src/main/java/com/microsoft/durabletask/AbstractTaskEntity.java @@ -329,6 +329,7 @@ private static Object invokeMethodDirect( } try { + makeDeclaringClassAccessible(method); return method.invoke(target, args); } catch (InvocationTargetException e) { Throwable cause = e.getTargetException(); @@ -421,6 +422,7 @@ private static Object invokeMethod(Method method, Object target, TaskEntityOpera } try { + makeDeclaringClassAccessible(method); return method.invoke(target, args); } catch (InvocationTargetException e) { // Unwrap the target exception @@ -431,4 +433,10 @@ private static Object invokeMethod(Method method, Object target, TaskEntityOpera throw new RuntimeException(cause); } } + + private static void makeDeclaringClassAccessible(Method method) { + if (!Modifier.isPublic(method.getDeclaringClass().getModifiers())) { + method.setAccessible(true); + } + } } 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/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java index 8e82a074..b7f6c60d 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/CommitCheckpointRequest.java @@ -11,7 +11,7 @@ * When {@link #getCheckpoint()} is non-null the cursor moves forward (successful batch); when {@code null} the * cursor is retained (failed batch eligible for retry). */ -public final class CommitCheckpointRequest { +final class CommitCheckpointRequest { private long scannedInstances; private long exportedInstances; diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java index f8fca728..8019594a 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExecuteExportJobOperationOrchestrator.java @@ -2,16 +2,19 @@ // 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. */ -public final class ExecuteExportJobOperationOrchestrator implements TaskOrchestration { +final class ExecuteExportJobOperationOrchestrator implements TaskOrchestration { /** The registered orchestration name. */ public static final String NAME = "ExecuteExportJobOperationOrchestrator"; @@ -25,3 +28,74 @@ public void run(TaskOrchestrationContext ctx) { 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 index 0829ab3a..c0c1efcd 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportBlobNaming.java @@ -9,6 +9,7 @@ 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 @@ -51,7 +52,7 @@ static String blobFileName(Instant completedTimestamp, String instanceId, Export static String formatTimestamp(Instant instant) { OffsetDateTime utc = instant.atOffset(ZoneOffset.UTC); long ticks = utc.getNano() / 100L; - return TIMESTAMP_DATE_TIME.format(utc) + "." + String.format("%07d", ticks) + "+00:00"; + return TIMESTAMP_DATE_TIME.format(utc) + "." + String.format(Locale.ROOT, "%07d", ticks) + "+00:00"; } /** diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java index 354c5c01..fa53c08a 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportFailure.java @@ -7,7 +7,7 @@ /** * Failure of a specific instance export. */ -public final class ExportFailure { +final class ExportFailure { private String instanceId; private String reason; diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java index cd9f1353..5d705d2b 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryConstants.java @@ -5,7 +5,7 @@ /** * Constants used throughout the export history functionality. */ -public final class ExportHistoryConstants { +final class ExportHistoryConstants { /** * The prefix used for generating export job orchestrator instance IDs. diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java index 52c0898c..50bec29f 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java @@ -12,6 +12,8 @@ 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 @@ -20,6 +22,7 @@ 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; @@ -96,14 +99,32 @@ public ExportJobDescription describe() { return ExportJobDescription.fromState(this.jobId, metadata.getState()); } - /** - * Deletes the export job entity. The export orchestrator self-exits on its next cycle once it observes the job - * is gone. - */ + /** 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); - scheduleAndWait(request); + 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) { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java index b1c7b0f2..9113b058 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryStorageOptions.java @@ -37,7 +37,6 @@ public final class ExportHistoryStorageOptions { private TokenCredential credential; private String containerName = ""; private String prefix; - private ExportFormat format = ExportFormat.getDefault(); /** * Gets the Azure Storage connection string. @@ -145,24 +144,4 @@ public ExportHistoryStorageOptions setPrefix(@Nullable String prefix) { this.prefix = prefix; return this; } - - /** - * Gets the export format. Defaults to {@link ExportFormat#getDefault()} (JSONL + gzip). - * - * @return the export format - */ - public ExportFormat getFormat() { - return this.format; - } - - /** - * Sets the export format. - * - * @param format the export format - * @return this options object - */ - public ExportHistoryStorageOptions setFormat(ExportFormat format) { - this.format = format; - 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 index 72480b40..02055c7c 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryWorkerExtensions.java @@ -44,7 +44,7 @@ public static DurableTaskGrpcWorkerBuilder useExportHistory( BlobExportWriter writer = new BlobExportWriter(storage); - builder.addEntity(ExportJob.NAME, ExportJob.class); + builder.addEntity(ExportJob.NAME, ExportJob::new); builder.addOrchestration(orchestrationFactory( ExportJobOrchestrator.NAME, ExportJobOrchestrator::new)); diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java index c6de0ad8..5a1911a9 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportInstanceHistoryActivity.java @@ -8,6 +8,7 @@ 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; @@ -20,7 +21,7 @@ * 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. */ -public final class ExportInstanceHistoryActivity implements TaskActivity { +final class ExportInstanceHistoryActivity implements TaskActivity { /** The registered activity name. */ public static final String NAME = "ExportInstanceHistoryActivity"; @@ -78,3 +79,176 @@ public Object run(TaskActivityContext ctx) { } } } + +/** + * 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 index 5c766ff2..24da772b 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJob.java @@ -19,7 +19,7 @@ * Operations are dispatched by method name (case-insensitive): {@code Create}, {@code Get}, {@code Run}, * {@code CommitCheckpoint}, {@code MarkAsCompleted}, {@code MarkAsFailed}, {@code Delete}. */ -public final class ExportJob extends AbstractTaskEntity { +final class ExportJob extends AbstractTaskEntity { /** The registered entity name. */ public static final String NAME = "ExportJob"; @@ -64,10 +64,6 @@ public void create(ExportJobCreationOptions creationOptions) { ? null : new ArrayList<>(creationOptions.getRuntimeStatus()); - // Resolve the completion-time lower bound to the job creation instant when the caller omitted it. This - // mirrors the .NET SDK (completedTimeFrom ?? UtcNow) so a CONTINUOUS job tails only completions that occur - // after the job is created, instead of re-exporting every historical instance in the task hub. BATCH always - // supplies an explicit lower bound (enforced by ExportJobCreationOptions.validateForCreate()). Instant now = Instant.now(); Instant completedTimeFrom = creationOptions.getCompletedTimeFrom() != null ? creationOptions.getCompletedTimeFrom() diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java deleted file mode 100644 index 91e26bb8..00000000 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOperationRequest.java +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.microsoft.durabletask.exporthistory; - -import com.microsoft.durabletask.EntityInstanceId; - -import javax.annotation.Nullable; - -/** - * 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. - */ -public 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/ExportJobOrchestrator.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java index 07083876..a8a0f8ba 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobOrchestrator.java @@ -24,7 +24,7 @@ * commits checkpoints to the {@link ExportJob} entity, and handles BATCH vs CONTINUOUS modes with bounded retries * and periodic {@code continueAsNew}. */ -public final class ExportJobOrchestrator implements TaskOrchestration { +final class ExportJobOrchestrator implements TaskOrchestration { /** The registered orchestration name. */ public static final String NAME = "ExportJobOrchestrator"; @@ -279,3 +279,56 @@ static BatchExportResult failed(int exportedCount, List 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/ExportJobRunRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobRunRequest.java deleted file mode 100644 index 4a5e7474..00000000 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobRunRequest.java +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.microsoft.durabletask.exporthistory; - -import com.microsoft.durabletask.EntityInstanceId; - -/** - * Input to the {@link ExportJobOrchestrator} identifying the job entity and the number of processed cycles - * (used to bound work before {@code continueAsNew}). - */ -public 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/ExportJobState.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java index f8d4ed87..67bdaa83 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobState.java @@ -8,7 +8,7 @@ /** * Export job state stored in the {@link ExportJob} entity. */ -public final class ExportJobState { +final class ExportJobState { private ExportJobStatus status; private ExportJobConfiguration config; diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java index 9a421f2e..9a71d79d 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobTransitions.java @@ -6,7 +6,7 @@ * Valid state-transition rules for export jobs. The operation-name constants match the {@link ExportJob} entity * method names (case-insensitive dispatch). */ -public final class ExportJobTransitions { +final class ExportJobTransitions { /** The {@code Create} operation name. */ public static final String OP_CREATE = "Create"; diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportRequest.java deleted file mode 100644 index 65b3f8d4..00000000 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportRequest.java +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.microsoft.durabletask.exporthistory; - -/** - * Input to {@link ExportInstanceHistoryActivity}: the instance to export plus the destination and format. - */ -public 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; - } -} diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportResult.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportResult.java deleted file mode 100644 index 5f296f4b..00000000 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportResult.java +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.microsoft.durabletask.exporthistory; - -import javax.annotation.Nullable; - -/** - * Output of {@link ExportInstanceHistoryActivity}: whether a single instance's history export succeeded, and the - * blob name written (or the error on failure). - */ -public 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/HistoryEventSerializer.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java index 08c6e7bc..bc4185f2 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java @@ -52,6 +52,7 @@ import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -356,7 +357,7 @@ private static String formatInstant(Instant t) { int nanos = utc.getNano(); if (nanos != 0) { // Up to seven fractional digits (100-ns ticks), trailing zeros trimmed. - String frac = String.format("%09d", nanos).substring(0, 7); + String frac = String.format(Locale.ROOT, "%09d", nanos).substring(0, 7); int end = frac.length(); while (end > 0 && frac.charAt(end - 1) == '0') { end--; diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/InstancePage.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/InstancePage.java deleted file mode 100644 index 1307834a..00000000 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/InstancePage.java +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.microsoft.durabletask.exporthistory; - -import javax.annotation.Nullable; -import java.util.ArrayList; -import java.util.List; - -/** - * Output of {@link ListTerminalInstancesActivity}: a page of terminal instance IDs plus the checkpoint to advance - * to once this page has been exported. - */ -public 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/ListTerminalInstancesActivity.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java index d8edb404..5c6e3987 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java @@ -5,16 +5,20 @@ 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. */ -public final class ListTerminalInstancesActivity implements TaskActivity { +final class ListTerminalInstancesActivity implements TaskActivity { /** The registered activity name. */ public static final String NAME = "ListTerminalInstancesActivity"; @@ -47,3 +51,169 @@ public Object run(TaskActivityContext ctx) { new ExportCheckpoint(result.getContinuationToken())); } } + +/** + * 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/ListTerminalInstancesRequest.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesRequest.java deleted file mode 100644 index 5446a60b..00000000 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesRequest.java +++ /dev/null @@ -1,121 +0,0 @@ -// 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; - -/** - * Input to {@link ListTerminalInstancesActivity}: a completion-time window, terminal status filter, pagination - * cursor, and batch size. - */ -public 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; - } -} diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java index 8fed8cec..1c3c805d 100644 --- a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java +++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportBlobNamingTest.java @@ -7,6 +7,7 @@ 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; @@ -63,6 +64,18 @@ void formatTimestamp_hasSevenFractionalDigitsAndUtcOffset() { 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. 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..dd905859 --- /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/ExportJobTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTest.java index 3df8eb31..15844462 100644 --- a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTest.java +++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTest.java @@ -15,7 +15,7 @@ import static org.mockito.Mockito.mock; /** - * Unit tests for the {@link ExportJob} entity's {@code create} operation. + * Unit tests for the {@link ExportJob} entity's {@code create} operation.s */ class ExportJobTest { diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerTest.java index b2753921..81e1d611 100644 --- a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerTest.java +++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializerTest.java @@ -11,6 +11,7 @@ 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; @@ -79,4 +80,19 @@ void contentType_byFormat() { 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); + } + } } From 963516cab7d4615275ab5ee2e2e9902c46b7c78d Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Wed, 22 Jul 2026 15:12:38 -0700 Subject: [PATCH 12/12] addressed PR feedback --- .../exporthistory/ExportHistoryJobClient.java | 5 ++- .../ExportJobCreationOptions.java | 3 ++ .../exporthistory/HistoryEventSerializer.java | 4 +- .../ListTerminalInstancesActivity.java | 16 +++++++- .../ExportHistoryJobClientTest.java | 38 +++++++++---------- .../exporthistory/ExportJobTest.java | 2 +- 6 files changed, 42 insertions(+), 26 deletions(-) diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java index 50bec29f..fa7bfd11 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClient.java @@ -93,10 +93,11 @@ public void create(ExportJobCreationOptions options) { public ExportJobDescription describe() { TypedEntityMetadata metadata = this.durableTaskClient.getEntities().getEntityMetadata(this.entityId, ExportJobState.class); - if (metadata == null) { + ExportJobState state = metadata == null ? null : metadata.getState(); + if (state == null) { throw new ExportJobNotFoundException(this.jobId); } - return ExportJobDescription.fromState(this.jobId, metadata.getState()); + return ExportJobDescription.fromState(this.jobId, state); } /** Deletes the export job entity, then terminates and purges its linked export orchestrator. */ diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java index 2bca3221..72411e13 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ExportJobCreationOptions.java @@ -186,6 +186,9 @@ public ExportFormat getFormat() { * @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; } diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java index bc4185f2..e2bc83ed 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/HistoryEventSerializer.java @@ -61,13 +61,13 @@ * 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 strings escaped by {@link HtmlSafeJsonEscapes}. + * {@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. + * reflective projection of the event with an added {@code eventType} discriminator (serialized with Jackson defaults). */ final class HistoryEventSerializer { diff --git a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java index 5c6e3987..51d7c646 100644 --- a/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java +++ b/exporthistory/src/main/java/com/microsoft/durabletask/exporthistory/ListTerminalInstancesActivity.java @@ -46,9 +46,21 @@ public Object run(TaskActivityContext ctx) { .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( - new ArrayList<>(result.getInstanceIds()), - new ExportCheckpoint(result.getContinuationToken())); + instanceIds, + nextKey == null ? null : new ExportCheckpoint(nextKey)); } } diff --git a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClientTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClientTest.java index dd905859..e91615bd 100644 --- a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClientTest.java +++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportHistoryJobClientTest.java @@ -57,26 +57,26 @@ void delete_terminatesWaitsForAndPurgesExportOrchestrator() throws TimeoutExcept 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"); + @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); + 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()); + 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); - } + 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/ExportJobTest.java b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTest.java index 15844462..3df8eb31 100644 --- a/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTest.java +++ b/exporthistory/src/test/java/com/microsoft/durabletask/exporthistory/ExportJobTest.java @@ -15,7 +15,7 @@ import static org.mockito.Mockito.mock; /** - * Unit tests for the {@link ExportJob} entity's {@code create} operation.s + * Unit tests for the {@link ExportJob} entity's {@code create} operation. */ class ExportJobTest {