Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/build-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
135 changes: 135 additions & 0 deletions exporthistory/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Durable Task Export History (Java)

Durable, resumable export of **terminal orchestration history** to Azure Blob Storage for the Durable Task Java
SDK — for compliance, audit, and offline analysis before instances age out of the task hub.

This module is at parity with the .NET `Microsoft.DurableTask.ExportHistory` (preview) feature: a checkpointed
entity + orchestrator that pages terminal instances by completion window, fans out per-instance export activities,
and uploads serialized history (gzipped JSONL by default) to a customer-owned blob container.

> **Status:** preview (`0.1.0`).

## Install

Add the module dependency alongside the core `client` (and your Durable Task Scheduler extension):

```groovy
implementation 'com.microsoft:durabletask-exporthistory:0.1.0'
```

The export activities upload to Azure Blob Storage via `azure-storage-blob`. If you authenticate with a managed
identity, also add `com.azure:azure-identity` to your application.

## Usage

```java
// Storage destination for exported history (Azure Blob)
ExportHistoryStorageOptions storage = new ExportHistoryStorageOptions()
.setConnectionString(System.getenv("EXPORT_HISTORY_STORAGE_CONNECTION_STRING"))
.setContainerName("orchestration-history")
.setPrefix("exports/"); // optional
// identity alt: .setAccountUri(uri).setCredential(new DefaultAzureCredentialBuilder().build())

// Build a client first — the export activities need a client to the same backend.
DurableTaskGrpcClientBuilder clientBuilder = new DurableTaskGrpcClientBuilder();
DurableTaskSchedulerClientExtensions.useDurableTaskScheduler(clientBuilder, dtsConn);
DurableTaskClient client = clientBuilder.build();

// Worker: register the export entity + orchestrators + activities (uploads run here).
DurableTaskGrpcWorkerBuilder workerBuilder = new DurableTaskGrpcWorkerBuilder();
DurableTaskSchedulerWorkerExtensions.useDurableTaskScheduler(workerBuilder, dtsConn);
ExportHistoryWorkerExtensions.useExportHistory(workerBuilder, storage, client);

// Client: obtain an ExportHistoryClient bound to the destination.
ExportHistoryClient export = ExportHistoryClientExtensions.useExportHistory(client, storage);

// Create a job: archive everything completed in a window.
ExportHistoryJobClient job = export.createJob(new ExportJobCreationOptions("nightly-archive")
.setMode(ExportMode.BATCH)
.setCompletedTimeFrom(Instant.parse("2026-06-01T00:00:00Z"))
.setCompletedTimeTo(Instant.parse("2026-06-25T00:00:00Z"))
.setRuntimeStatus(List.of(OrchestrationRuntimeStatus.COMPLETED))
.setMaxInstancesPerBatch(200)); // 1–1000, default 100

// Inspect progress.
ExportJobDescription d = job.describe();
System.out.println(d.getStatus() + " exported=" + d.getExportedInstances());
```

### Modes

- **BATCH** — exports a fixed completion-time window and completes. Requires `completedTimeFrom` and
`completedTimeTo` (the upper bound must not be in the future).
- **CONTINUOUS** — tails newly-completed terminal instances on a 1-minute idle loop until the job is deleted.

### Terminal statuses only

Export supports terminal orchestration statuses only: `COMPLETED`, `FAILED`, `TERMINATED`. When no status filter is
supplied, all three are exported.

## Required settings

- **DTS connection** — the one your app already uses; no new value.
- **Blob destination** — a container name plus either a storage **connection string** or **identity**
(`AccountUri` + `TokenCredential`). Prefix and format (JSONL + gzip) are optional with defaults. The storage
secret is held worker-side, not persisted in task-hub state.
- **Permissions** — the storage credential needs blob write on the container; the DTS credential needs
orchestration read.

## Export format

Each blob holds the instance's full history, and the **blob body is byte-for-byte identical to the .NET
`Microsoft.DurableTask.ExportHistory` output** (pinned by a test against golden output captured from
`Microsoft.Azure.DurableTask.Core`):

- **JSONL** (default, gzipped) is one JSON object per line; **JSON** is a single array.
- Each event is `{"eventType": "...", <type-specific fields>, "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 `"<completedTimestamp>|<instanceId>"` plus the format extension.

## Differences from .NET

1. **Worker registration takes an explicit client** — `useExportHistory(workerBuilder, storage, client)`. Java has
no dependency injection, so the export activities require a `DurableTaskClient` for the same backend.
2. **Entity events** — the .NET export folds durable-entity operations into core events via a stateful converter, so
it has no distinct JSON for them; Java emits entity events in a Java-native shape instead (a reflective projection
with an `eventType` discriminator, e.g. `"EntityLockGranted"` — matching the Python SDK, which also keeps entity
events as first-class types). Every **non-entity** event is byte-for-byte identical to .NET, so only orchestrations
that call entities differ, and only on those entity-specific lines.

## Backend requirement

The export feature relies on the `ListInstanceIds` and `StreamInstanceHistory` gRPC operations. Managed DTS serves
both; the emulator / self-hosted sidecar needs **≥ v0.4.22**. Against an older backend, a raw gRPC `UNIMPLEMENTED`
surfaces (matching .NET).

## Validating the export

Locally, with the DTS emulator and Azurite:

1. Start the backends:
```
docker run --name durabletask-emulator -p 4001:8080 -d mcr.microsoft.com/dts/dts-emulator:latest
docker run --name azurite -p 10000:10000 -d mcr.microsoft.com/azure-storage/azurite azurite-blob --blobHost 0.0.0.0
```
2. Point the app at them: DTS connection `Endpoint=http://localhost:4001;Authentication=None`, storage = the Azurite
dev connection string, container `orchestration-history`.
3. Run an orchestration to a terminal state, then create a `BATCH` export job whose window covers its completion time.
4. Confirm the job reaches `COMPLETED` and inspect progress:
```java
ExportJobDescription d = job.describe();
// d.getStatus() == ExportJobStatus.COMPLETED, d.getExportedInstances() >= 1
```
5. Download the blob from the container (gunzip for JSONL) and inspect it. Every line carries an `eventType`
discriminator, `isPlayed:false`, and a trailing `timestamp`.

## Sample

See [`HistoryExportSample`](../samples/src/main/java/io/durabletask/samples/HistoryExportSample.java):

```
./gradlew :samples:runHistoryExportSample
```
173 changes: 173 additions & 0 deletions exporthistory/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
plugins {
id 'java-library'
id 'maven-publish'
id 'signing'
id 'com.github.spotbugs' version '6.4.8'
}

group 'com.microsoft'
version = '0.1.0'
archivesBaseName = 'durabletask-exporthistory'

def grpcVersion = '1.78.0'
def azureCoreVersion = '1.57.1'
def azureStorageBlobVersion = '12.29.1'

// Java 11 is used to compile and run all tests. Set the JDK_11 env var to your
// local JDK 11 home directory, e.g. C:/Program Files/Java/openjdk-11.0.12_7/
// If unset, falls back to the current JDK running Gradle.
def rawJdkPath = System.env.JDK_11 ?: System.getProperty("java.home")
def PATH_TO_TEST_JAVA_RUNTIME = rawJdkPath
if (rawJdkPath != null) {
def f = new File(rawJdkPath)
if (f.isFile()) {
PATH_TO_TEST_JAVA_RUNTIME = f.parentFile.parentFile.absolutePath
}
}
def isWindows = System.getProperty("os.name").toLowerCase().contains("win")
def exeSuffix = isWindows ? ".exe" : ""

dependencies {
api project(':client')

// Azure Storage Blobs — export destination for serialized history.
implementation "com.azure:azure-storage-blob:${azureStorageBlobVersion}"

// TokenCredential abstraction (from azure-core) — 'api' because
// ExportHistoryStorageOptions exposes TokenCredential in its public API.
api "com.azure:azure-core:${azureCoreVersion}"

// gRPC API (used by the export activities that call the client wrappers).
implementation "io.grpc:grpc-api:${grpcVersion}"
implementation "io.grpc:grpc-protobuf:${grpcVersion}"
implementation "io.grpc:grpc-stub:${grpcVersion}"

// NOTE: azure-identity is NOT included here. Users who need
// DefaultAzureCredential should add it to their own project.

testImplementation 'org.mockito:mockito-core:5.21.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.21.0'
testImplementation project(':azuremanaged')
}

compileJava {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
compileTestJava {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
options.fork = true
options.forkOptions.executable = "${PATH_TO_TEST_JAVA_RUNTIME}/bin/javac${exeSuffix}"
}

tasks.withType(Test) {
executable = new File("${PATH_TO_TEST_JAVA_RUNTIME}", "bin/java${exeSuffix}")
}

test {
useJUnitPlatform {
// Skip tests tagged as "integration" since those require
// external dependencies (DTS emulator + Azurite).
excludeTags "integration"
}
}

// Integration tests require DTS emulator (default localhost:4001) and Azurite on localhost:10000.
task integrationTest(type: Test) {
useJUnitPlatform {
includeTags 'integration'
}
dependsOn build
shouldRunAfter test
testLogging.showStandardStreams = true
ignoreFailures = false
}

spotbugs {
toolVersion = '4.9.8'
effort = com.github.spotbugs.snom.Effort.valueOf('MAX')
reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH')
ignoreFailures = true
}

spotbugsMain {
reports {
html {
required = true
stylesheet = 'fancy-hist.xsl'
}
xml {
required = true
}
}
}

spotbugsTest {
reports {
html {
required = true
stylesheet = 'fancy-hist.xsl'
}
xml {
required = true
}
}
}

publishing {
repositories {
maven {
url "file://$project.rootDir/repo"
}
}
publications {
mavenJava(MavenPublication) {
from components.java
artifactId = archivesBaseName
pom {
name = 'Durable Task Export History for Java'
description = 'This package provides durable export of terminal orchestration history to Azure Blob Storage for the Durable Task Java SDK.'
url = "https://github.com/microsoft/durabletask-java/tree/main/exporthistory"
licenses {
license {
name = "MIT License"
url = "https://opensource.org/licenses/MIT"
distribution = "repo"
}
}
developers {
developer {
id = "Microsoft"
name = "Microsoft Corporation"
}
}
scm {
connection = "scm:git:https://github.com/microsoft/durabletask-java"
developerConnection = "scm:git:git@github.com:microsoft/durabletask-java"
url = "https://github.com/microsoft/durabletask-java/tree/main/exporthistory"
}
withXml {
project.configurations.compileOnly.allDependencies.each { dependency ->
asNode().dependencies[0].appendNode("dependency").with {
it.appendNode("groupId", dependency.group)
it.appendNode("artifactId", dependency.name)
it.appendNode("version", dependency.version)
it.appendNode("scope", "provided")
}
}
}
}
}
}
}

signing {
required = !project.hasProperty("skipSigning")
sign publishing.publications.mavenJava
}

java {
withSourcesJar()
withJavadocJar()
}
Loading