Skip to content

Commit f6c9681

Browse files
authored
Add export history support (#293)
1 parent c2c7efa commit f6c9681

60 files changed

Lines changed: 6094 additions & 3 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build-validation.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ jobs:
142142
name: Integration test report
143143
path: client/build/reports/tests/integrationTest
144144

145+
- name: Archive export history integration test report
146+
if: always()
147+
uses: actions/upload-artifact@v4
148+
with:
149+
name: Export history integration test report
150+
path: exporthistory/build/reports/tests/integrationTest
151+
if-no-files-found: ignore
152+
145153
- name: Upload JAR output
146154
uses: actions/upload-artifact@v4
147155
with:

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
## Unreleased
2+
* Add the `exporthistory` module for durable, checkpointed export of terminal orchestration history to Azure Blob Storage ([#293](https://github.com/microsoft/durabletask-java/pull/293))
23
* Add client APIs to list terminal instance IDs by completion time (`listInstanceIds`) and read orchestration history (`getOrchestrationHistory`) ([#292](https://github.com/microsoft/durabletask-java/pull/292))
34
* Add `createReplaySafeLogger` to suppress orchestration log output during replay ([#295](https://github.com/microsoft/durabletask-java/pull/295)).
45
* Add `getParentInstance()` API to `TaskOrchestrationContext` for discovering parent orchestration info ([#284](https://github.com/microsoft/durabletask-java/pull/284))

client/src/main/java/com/microsoft/durabletask/AbstractTaskEntity.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,13 @@ private static Optional<Method> findMethodUncached(Class<?> targetClass, String
392392
"Ambiguous match: multiple methods named '" + operationName + "' found on " +
393393
targetClass.getName() + ". Entity operation methods must have unique names.");
394394
}
395-
return matches.isEmpty() ? Optional.empty() : Optional.of(matches.get(0));
395+
if (matches.isEmpty()) {
396+
return Optional.empty();
397+
}
398+
// Resolve reflective accessibility once, at method-caching time, rather than on every dispatch.
399+
Method resolved = matches.get(0);
400+
makeDeclaringClassAccessible(resolved);
401+
return Optional.of(resolved);
396402
}
397403

398404
/**
@@ -431,4 +437,19 @@ private static Object invokeMethod(Method method, Object target, TaskEntityOpera
431437
throw new RuntimeException(cause);
432438
}
433439
}
440+
441+
private static void makeDeclaringClassAccessible(Method method) {
442+
if (!Modifier.isPublic(method.getDeclaringClass().getModifiers())) {
443+
try {
444+
method.setAccessible(true);
445+
} catch (RuntimeException ex) {
446+
// Under JPMS (Java 16+), setAccessible throws InaccessibleObjectException when the entity's
447+
// package is not open for reflection. Surface an actionable message instead of the raw error.
448+
throw new IllegalStateException(
449+
"Unable to access entity method '" + method.getName() + "' on non-public class '"
450+
+ method.getDeclaringClass().getName() + "'. Make the entity class public, or "
451+
+ "open its package for reflection (e.g. 'opens' in module-info.java).", ex);
452+
}
453+
}
454+
}
434455
}

client/src/test/java/com/microsoft/durabletask/TaskEntityTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,23 @@ protected Class<Integer> getStateType() {
4242
}
4343
}
4444

45+
/** A non-public entity implementation, matching internal feature entities. */
46+
private static class PrivateEntity extends AbstractTaskEntity<String> {
47+
public String get() {
48+
return this.state;
49+
}
50+
51+
@Override
52+
protected String initializeState(TaskEntityOperation operation) {
53+
return "private-state";
54+
}
55+
56+
@Override
57+
protected Class<String> getStateType() {
58+
return String.class;
59+
}
60+
}
61+
4562
/**
4663
* Entity that accepts a TaskEntityContext as a method parameter.
4764
*/
@@ -247,6 +264,12 @@ void reflectionDispatch_methodWithReturnValue() throws Exception {
247264
assertEquals(42, result);
248265
}
249266

267+
@Test
268+
void reflectionDispatch_publicOperationOnPrivateEntityClass() throws Exception {
269+
Object result = new PrivateEntity().run(createOperation("get"));
270+
assertEquals("private-state", result);
271+
}
272+
250273
@Test
251274
void reflectionDispatch_caseInsensitive() throws Exception {
252275
CounterEntity entity = new CounterEntity();

exporthistory/README.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Durable Task Export History (Java)
2+
3+
Durable, resumable export of **terminal orchestration history** to Azure Blob Storage for the Durable Task Java
4+
SDK — for compliance, audit, and offline analysis before instances age out of the task hub.
5+
6+
This module is at parity with the .NET `Microsoft.DurableTask.ExportHistory` (preview) feature: a checkpointed
7+
entity + orchestrator that pages terminal instances by completion window, fans out per-instance export activities,
8+
and uploads serialized history (gzipped JSONL by default) to a customer-owned blob container.
9+
10+
> **Status:** preview (`0.1.0`).
11+
12+
## Install
13+
14+
Add the module dependency alongside the core `client` (and your Durable Task Scheduler extension):
15+
16+
```groovy
17+
implementation 'com.microsoft:durabletask-exporthistory:0.1.0'
18+
```
19+
20+
The export activities upload to Azure Blob Storage via `azure-storage-blob`. If you authenticate with a managed
21+
identity, also add `com.azure:azure-identity` to your application.
22+
23+
## Usage
24+
25+
```java
26+
// Storage destination for exported history (Azure Blob)
27+
ExportHistoryStorageOptions storage = new ExportHistoryStorageOptions()
28+
.setConnectionString(System.getenv("EXPORT_HISTORY_STORAGE_CONNECTION_STRING"))
29+
.setContainerName("orchestration-history")
30+
.setPrefix("exports/"); // optional
31+
// identity alt: .setAccountUri(uri).setCredential(new DefaultAzureCredentialBuilder().build())
32+
33+
// Build a client first — the export activities need a client to the same backend.
34+
DurableTaskGrpcClientBuilder clientBuilder = new DurableTaskGrpcClientBuilder();
35+
DurableTaskSchedulerClientExtensions.useDurableTaskScheduler(clientBuilder, dtsConn);
36+
DurableTaskClient client = clientBuilder.build();
37+
38+
// Worker: register the export entity + orchestrators + activities (uploads run here).
39+
DurableTaskGrpcWorkerBuilder workerBuilder = new DurableTaskGrpcWorkerBuilder();
40+
DurableTaskSchedulerWorkerExtensions.useDurableTaskScheduler(workerBuilder, dtsConn);
41+
ExportHistoryWorkerExtensions.useExportHistory(workerBuilder, storage, client);
42+
43+
// Client: obtain an ExportHistoryClient bound to the destination.
44+
ExportHistoryClient export = ExportHistoryClientExtensions.useExportHistory(client, storage);
45+
46+
// Create a job: archive everything completed in a window.
47+
ExportHistoryJobClient job = export.createJob(new ExportJobCreationOptions("nightly-archive")
48+
.setMode(ExportMode.BATCH)
49+
.setCompletedTimeFrom(Instant.parse("2026-06-01T00:00:00Z"))
50+
.setCompletedTimeTo(Instant.parse("2026-06-25T00:00:00Z"))
51+
.setRuntimeStatus(List.of(OrchestrationRuntimeStatus.COMPLETED))
52+
.setMaxInstancesPerBatch(200)); // 1–1000, default 100
53+
54+
// Inspect progress.
55+
ExportJobDescription d = job.describe();
56+
System.out.println(d.getStatus() + " exported=" + d.getExportedInstances());
57+
```
58+
59+
### Modes
60+
61+
- **BATCH** — exports a fixed completion-time window and completes. Requires `completedTimeFrom` and
62+
`completedTimeTo` (the upper bound must not be in the future).
63+
- **CONTINUOUS** — tails newly-completed terminal instances on a 1-minute idle loop until the job is deleted.
64+
65+
### Terminal statuses only
66+
67+
Export supports terminal orchestration statuses only: `COMPLETED`, `FAILED`, `TERMINATED`. When no status filter is
68+
supplied, all three are exported.
69+
70+
## Required settings
71+
72+
- **DTS connection** — the one your app already uses; no new value.
73+
- **Blob destination** — a container name plus either a storage **connection string** or **identity**
74+
(`AccountUri` + `TokenCredential`). Prefix and format (JSONL + gzip) are optional with defaults. The storage
75+
secret is held worker-side, not persisted in task-hub state.
76+
- **Permissions** — the storage credential needs blob write on the container; the DTS credential needs
77+
orchestration read.
78+
79+
## Export format
80+
81+
Each blob holds the instance's full history, and the **blob body is byte-for-byte identical to the .NET
82+
`Microsoft.DurableTask.ExportHistory` output** (pinned by a test against golden output captured from
83+
`Microsoft.Azure.DurableTask.Core`):
84+
85+
- **JSONL** (default, gzipped) is one JSON object per line; **JSON** is a single array.
86+
- Each event is `{"eventType": "...", <type-specific fields>, "eventId": N, "isPlayed": false, "timestamp": "..."}`.
87+
- camelCase field names, null fields omitted, empty maps as `{}`, enum values in PascalCase (e.g. `"Completed"`),
88+
timestamps as trimmed ISO-8601 ending in `Z`, and the same HTML-safe string escaping (`"``\u0022`,
89+
`& < > ' +` and all non-ASCII → `\uXXXX`).
90+
91+
Blob **names**: a lowercase-hex SHA-256 of `"<completedTimestamp>|<instanceId>"` plus the format extension.
92+
93+
## Backend requirement
94+
95+
The export feature relies on the `ListInstanceIds` and `StreamInstanceHistory` gRPC operations. Managed DTS serves
96+
both; the emulator / self-hosted sidecar needs **≥ v0.4.22**. Against an older backend, a raw gRPC `UNIMPLEMENTED`
97+
surfaces (matching .NET).
98+
99+
## Validating the export
100+
101+
Locally, with the DTS emulator and Azurite:
102+
103+
1. Start the backends:
104+
```
105+
docker run --name durabletask-emulator -p 4001:8080 -d mcr.microsoft.com/dts/dts-emulator:latest
106+
docker run --name azurite -p 10000:10000 -d mcr.microsoft.com/azure-storage/azurite azurite-blob --blobHost 0.0.0.0
107+
```
108+
2. Point the app at them: DTS connection `Endpoint=http://localhost:4001;Authentication=None`, storage = the Azurite
109+
dev connection string, container `orchestration-history`.
110+
3. Run an orchestration to a terminal state, then create a `BATCH` export job whose window covers its completion time.
111+
4. Confirm the job reaches `COMPLETED` and inspect progress:
112+
```java
113+
ExportJobDescription d = job.describe();
114+
// d.getStatus() == ExportJobStatus.COMPLETED, d.getExportedInstances() >= 1
115+
```
116+
5. Download the blob from the container (gunzip for JSONL) and inspect it. Every line carries an `eventType`
117+
discriminator, `isPlayed:false`, and a trailing `timestamp`.
118+
119+
## Sample
120+
121+
See [`HistoryExportSample`](../samples/src/main/java/io/durabletask/samples/HistoryExportSample.java):
122+
123+
```
124+
./gradlew :samples:runHistoryExportSample
125+
```

exporthistory/build.gradle

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
plugins {
2+
id 'java-library'
3+
id 'maven-publish'
4+
id 'signing'
5+
id 'com.github.spotbugs' version '6.4.8'
6+
}
7+
8+
group 'com.microsoft'
9+
version = '0.1.0'
10+
archivesBaseName = 'durabletask-exporthistory'
11+
12+
def grpcVersion = '1.78.0'
13+
def azureCoreVersion = '1.57.1'
14+
def azureStorageBlobVersion = '12.29.1'
15+
16+
// Java 11 is used to compile and run all tests. Set the JDK_11 env var to your
17+
// local JDK 11 home directory, e.g. C:/Program Files/Java/openjdk-11.0.12_7/
18+
// If unset, falls back to the current JDK running Gradle.
19+
def rawJdkPath = System.env.JDK_11 ?: System.getProperty("java.home")
20+
def PATH_TO_TEST_JAVA_RUNTIME = rawJdkPath
21+
if (rawJdkPath != null) {
22+
def f = new File(rawJdkPath)
23+
if (f.isFile()) {
24+
PATH_TO_TEST_JAVA_RUNTIME = f.parentFile.parentFile.absolutePath
25+
}
26+
}
27+
def isWindows = System.getProperty("os.name").toLowerCase().contains("win")
28+
def exeSuffix = isWindows ? ".exe" : ""
29+
30+
dependencies {
31+
api project(':client')
32+
33+
// Azure Storage Blobs — export destination for serialized history.
34+
implementation "com.azure:azure-storage-blob:${azureStorageBlobVersion}"
35+
36+
// TokenCredential abstraction (from azure-core) — 'api' because
37+
// ExportHistoryStorageOptions exposes TokenCredential in its public API.
38+
api "com.azure:azure-core:${azureCoreVersion}"
39+
40+
// gRPC API (used by the export activities that call the client wrappers).
41+
implementation "io.grpc:grpc-api:${grpcVersion}"
42+
implementation "io.grpc:grpc-protobuf:${grpcVersion}"
43+
implementation "io.grpc:grpc-stub:${grpcVersion}"
44+
45+
// NOTE: azure-identity is NOT included here. Users who need
46+
// DefaultAzureCredential should add it to their own project.
47+
48+
testImplementation 'org.mockito:mockito-core:5.21.0'
49+
testImplementation 'org.mockito:mockito-junit-jupiter:5.21.0'
50+
testImplementation project(':azuremanaged')
51+
}
52+
53+
compileJava {
54+
sourceCompatibility = JavaVersion.VERSION_1_8
55+
targetCompatibility = JavaVersion.VERSION_1_8
56+
}
57+
compileTestJava {
58+
sourceCompatibility = JavaVersion.VERSION_11
59+
targetCompatibility = JavaVersion.VERSION_11
60+
options.fork = true
61+
options.forkOptions.executable = "${PATH_TO_TEST_JAVA_RUNTIME}/bin/javac${exeSuffix}"
62+
}
63+
64+
tasks.withType(Test) {
65+
executable = new File("${PATH_TO_TEST_JAVA_RUNTIME}", "bin/java${exeSuffix}")
66+
}
67+
68+
test {
69+
useJUnitPlatform {
70+
// Skip tests tagged as "integration" since those require
71+
// external dependencies (DTS emulator + Azurite).
72+
excludeTags "integration"
73+
}
74+
}
75+
76+
// Integration tests require DTS emulator (default localhost:4001) and Azurite on localhost:10000.
77+
task integrationTest(type: Test) {
78+
useJUnitPlatform {
79+
includeTags 'integration'
80+
}
81+
dependsOn build
82+
shouldRunAfter test
83+
testLogging.showStandardStreams = true
84+
ignoreFailures = false
85+
}
86+
87+
spotbugs {
88+
toolVersion = '4.9.8'
89+
effort = com.github.spotbugs.snom.Effort.valueOf('MAX')
90+
reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH')
91+
ignoreFailures = true
92+
}
93+
94+
spotbugsMain {
95+
reports {
96+
html {
97+
required = true
98+
stylesheet = 'fancy-hist.xsl'
99+
}
100+
xml {
101+
required = true
102+
}
103+
}
104+
}
105+
106+
spotbugsTest {
107+
reports {
108+
html {
109+
required = true
110+
stylesheet = 'fancy-hist.xsl'
111+
}
112+
xml {
113+
required = true
114+
}
115+
}
116+
}
117+
118+
publishing {
119+
repositories {
120+
maven {
121+
url "file://$project.rootDir/repo"
122+
}
123+
}
124+
publications {
125+
mavenJava(MavenPublication) {
126+
from components.java
127+
artifactId = archivesBaseName
128+
pom {
129+
name = 'Durable Task Export History for Java'
130+
description = 'This package provides durable export of terminal orchestration history to Azure Blob Storage for the Durable Task Java SDK.'
131+
url = "https://github.com/microsoft/durabletask-java/tree/main/exporthistory"
132+
licenses {
133+
license {
134+
name = "MIT License"
135+
url = "https://opensource.org/licenses/MIT"
136+
distribution = "repo"
137+
}
138+
}
139+
developers {
140+
developer {
141+
id = "Microsoft"
142+
name = "Microsoft Corporation"
143+
}
144+
}
145+
scm {
146+
connection = "scm:git:https://github.com/microsoft/durabletask-java"
147+
developerConnection = "scm:git:git@github.com:microsoft/durabletask-java"
148+
url = "https://github.com/microsoft/durabletask-java/tree/main/exporthistory"
149+
}
150+
withXml {
151+
project.configurations.compileOnly.allDependencies.each { dependency ->
152+
asNode().dependencies[0].appendNode("dependency").with {
153+
it.appendNode("groupId", dependency.group)
154+
it.appendNode("artifactId", dependency.name)
155+
it.appendNode("version", dependency.version)
156+
it.appendNode("scope", "provided")
157+
}
158+
}
159+
}
160+
}
161+
}
162+
}
163+
}
164+
165+
signing {
166+
required = !project.hasProperty("skipSigning")
167+
sign publishing.publications.mavenJava
168+
}
169+
170+
java {
171+
withSourcesJar()
172+
withJavadocJar()
173+
}

0 commit comments

Comments
 (0)