From 3fb6cce896a8b28339450a22e66ccdd8da545125 Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Tue, 30 Jun 2026 16:42:25 -0700 Subject: [PATCH 1/3] 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 3286187..1982b99 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 0000000..1cfb639 --- /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 0000000..486cff9 --- /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 0000000..68b1421 --- /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 0000000..ca8101e --- /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 0000000..0a54bb7 --- /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 0000000..ac068c2 --- /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 0000000..de4e8e4 --- /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 0000000..3e03f1a --- /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 0000000..3c4401d --- /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 0000000..53578d4 --- /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 0000000..71bf8d1 --- /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 0000000..f575734 --- /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 0000000..192c051 --- /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 0000000..56b0cd6 --- /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 0000000..296d395 --- /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 0000000..140cc33 --- /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 0000000..031a46f --- /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 0000000..a1a1766 --- /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 0000000..d111673 --- /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 0000000..4649d01 --- /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 0000000..d6a43e5 --- /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 0000000..e9c4792 --- /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 0000000..27d4b36 --- /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 0000000..6fe61c0 --- /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 0000000..9a3ee31 --- /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 0000000..0ae4140 --- /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 0000000..021d6c1 --- /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 0000000..161997c --- /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 0000000..b76a2df --- /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 0000000..0956027 --- /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 0000000..f3ca9ff --- /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 0000000..60a7e4d --- /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 0000000..d7eed69 --- /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 0000000..f751a07 --- /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 0000000..196e14c --- /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 0000000..65b3f8d --- /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 0000000..5f296f4 --- /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 0000000..716ac2d --- /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 0000000..1307834 --- /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 0000000..b470eea --- /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 0000000..5446a60 --- /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 0000000..666642c --- /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 0000000..5a8f70d --- /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 0000000..123b0bd --- /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 0000000..eb04917 --- /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 0000000..580fad3 --- /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 0000000..66c4c3c --- /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 0000000..6eaf610 --- /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 0000000..b275392 --- /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 e87347c..77c3f63 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 0000000..7aa300f --- /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 afe0838..03574d3 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 a501acb032da0c2bfd6922e0529eefaa6d1ed51d Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Wed, 1 Jul 2026 16:20:51 -0700 Subject: [PATCH 2/3] 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 1cfb639..ee0ef49 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 486cff9..e24fa0c 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 68b1421..5330b5e 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 ca8101e..8e82a07 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 0a54bb7..f8fca72 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 ac068c2..0829ab3 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 de4e8e4..ed1af60 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 3e03f1a..0922bf2 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 3c4401d..354c5c0 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 53578d4..7df1b37 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 71bf8d1..683ba00 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 f575734..1488eae 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 192c051..5819a8a 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 56b0cd6..7a4680b 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 296d395..cd9f135 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 140cc33..6992b06 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 031a46f..b1c7b0f 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 a1a1766..72480b4 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 d111673..1e0bde4 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 4649d01..1ab0418 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 d6a43e5..a7f4f10 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 e9c4792..097f6a0 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 27d4b36..2bca322 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 6fe61c0..3f7d16d 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 9a3ee31..8550be5 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 0ae4140..8cecefb 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 021d6c1..91e26bb 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 161997c..0708387 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 b76a2df..e888f3e 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 f3ca9ff..4a5e747 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 60a7e4d..f8d4ed8 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 d7eed69..7931c2b 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 f751a07..9a421f2 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 196e14c..c96399c 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 716ac2d..08c6e7b 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 0000000..f4d25bf --- /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 b470eea..d8edb40 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 666642c..eaa1900 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 5a8f70d..8fed8ce 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 0000000..65dbf18 --- /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 0000000..01cc537 --- /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 3229a390b8fb45926e9cb8a3b8f63d8b1722dbfc Mon Sep 17 00:00:00 2001 From: Varshi Bachu Date: Thu, 2 Jul 2026 12:58:51 -0700 Subject: [PATCH 3/3] 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 5330b5e..eec1808 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 6992b06..52c0898 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 1e0bde4..c6de0ad 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"); }