From 871eefeb73fe2f7720fc3fe81e6c783e2f36fbcf Mon Sep 17 00:00:00 2001
From: peterstone2017 <12449837+YunchuWang@users.noreply.github.com>
Date: Wed, 29 Oct 2025 12:53:31 -0700
Subject: [PATCH 1/7] export job
---
.../ExportHistoryWebApp.csproj | 23 ++
.../ExportHistoryWebApp.http | 88 +++++
.../ExportJobController.cs | 190 ++++++++++
.../Models/CreateExportJobRequest.cs | 60 ++++
samples/ExportHistoryWebApp/Program.cs | 55 +++
.../Properties/launchSettings.json | 26 ++
.../appsettings.Development.json | 9 +
samples/ExportHistoryWebApp/appsettings.json | 10 +
src/Client/Grpc/GrpcDurableTaskClient.cs | 50 +++
.../ExportInstanceHistoryActivity.cs | 291 +++++++++++++++
.../ListTerminalInstancesActivity.cs | 106 ++++++
.../Client/DefaultExportHistoryClient.cs | 192 ++++++++++
.../Client/DefaultExportHistoryJobClient.cs | 299 ++++++++++++++++
.../Client/ExportHistoryClient.cs | 19 +
.../Client/ExportHistoryJobClient.cs | 29 ++
.../Constants/ExportHistoryConstants.cs | 33 ++
src/ExportHistory/Entity/ExportJob.cs | 264 ++++++++++++++
.../Entity/ExportJobOperations.cs | 21 ++
.../ExportJobClientValidationException.cs | 39 ++
.../ExportJobInvalidTransitionException.cs | 64 ++++
.../Exception/ExportJobNotFoundException.cs | 36 ++
src/ExportHistory/ExportHistory.csproj | 26 ++
.../DurableTaskClientBuilderExtensions.cs | 51 +++
.../DurableTaskWorkerBuilderExtensions.cs | 27 ++
src/ExportHistory/Logging/Logs.Client.cs | 22 ++
src/ExportHistory/Logging/Logs.Entity.cs | 24 ++
.../Models/CommitCheckpointRequest.cs | 32 ++
src/ExportHistory/Models/ExportCheckpoint.cs | 26 ++
src/ExportHistory/Models/ExportDestination.cs | 32 ++
src/ExportHistory/Models/ExportFailure.cs | 14 +
src/ExportHistory/Models/ExportFilter.cs | 12 +
src/ExportHistory/Models/ExportFormat.cs | 8 +
.../Models/ExportJobConfiguration.cs | 12 +
.../Models/ExportJobCreationOptions.cs | 112 ++++++
.../Models/ExportJobDescription.cs | 42 +++
src/ExportHistory/Models/ExportJobQuery.cs | 45 +++
src/ExportHistory/Models/ExportJobState.cs | 61 ++++
src/ExportHistory/Models/ExportJobStatus.cs | 30 ++
.../Models/ExportJobTransitions.cs | 43 +++
src/ExportHistory/Models/ExportMode.cs | 17 +
.../Options/ExportHistoryStorageOptions.cs | 27 ++
.../ExecuteExportJobOperationOrchestrator.cs | 28 ++
.../Orchestrations/ExportJobOrchestrator.cs | 334 ++++++++++++++++++
src/Grpc/orchestrator_service.proto | 24 +-
.../Sidecar/Grpc/TaskHubGrpcServer.cs | 89 ++++-
45 files changed, 3038 insertions(+), 4 deletions(-)
create mode 100644 samples/ExportHistoryWebApp/ExportHistoryWebApp.csproj
create mode 100644 samples/ExportHistoryWebApp/ExportHistoryWebApp.http
create mode 100644 samples/ExportHistoryWebApp/ExportJobController.cs
create mode 100644 samples/ExportHistoryWebApp/Models/CreateExportJobRequest.cs
create mode 100644 samples/ExportHistoryWebApp/Program.cs
create mode 100644 samples/ExportHistoryWebApp/Properties/launchSettings.json
create mode 100644 samples/ExportHistoryWebApp/appsettings.Development.json
create mode 100644 samples/ExportHistoryWebApp/appsettings.json
create mode 100644 src/ExportHistory/Activities/ExportInstanceHistoryActivity.cs
create mode 100644 src/ExportHistory/Activities/ListTerminalInstancesActivity.cs
create mode 100644 src/ExportHistory/Client/DefaultExportHistoryClient.cs
create mode 100644 src/ExportHistory/Client/DefaultExportHistoryJobClient.cs
create mode 100644 src/ExportHistory/Client/ExportHistoryClient.cs
create mode 100644 src/ExportHistory/Client/ExportHistoryJobClient.cs
create mode 100644 src/ExportHistory/Constants/ExportHistoryConstants.cs
create mode 100644 src/ExportHistory/Entity/ExportJob.cs
create mode 100644 src/ExportHistory/Entity/ExportJobOperations.cs
create mode 100644 src/ExportHistory/Exception/ExportJobClientValidationException.cs
create mode 100644 src/ExportHistory/Exception/ExportJobInvalidTransitionException.cs
create mode 100644 src/ExportHistory/Exception/ExportJobNotFoundException.cs
create mode 100644 src/ExportHistory/ExportHistory.csproj
create mode 100644 src/ExportHistory/Extension/DurableTaskClientBuilderExtensions.cs
create mode 100644 src/ExportHistory/Extension/DurableTaskWorkerBuilderExtensions.cs
create mode 100644 src/ExportHistory/Logging/Logs.Client.cs
create mode 100644 src/ExportHistory/Logging/Logs.Entity.cs
create mode 100644 src/ExportHistory/Models/CommitCheckpointRequest.cs
create mode 100644 src/ExportHistory/Models/ExportCheckpoint.cs
create mode 100644 src/ExportHistory/Models/ExportDestination.cs
create mode 100644 src/ExportHistory/Models/ExportFailure.cs
create mode 100644 src/ExportHistory/Models/ExportFilter.cs
create mode 100644 src/ExportHistory/Models/ExportFormat.cs
create mode 100644 src/ExportHistory/Models/ExportJobConfiguration.cs
create mode 100644 src/ExportHistory/Models/ExportJobCreationOptions.cs
create mode 100644 src/ExportHistory/Models/ExportJobDescription.cs
create mode 100644 src/ExportHistory/Models/ExportJobQuery.cs
create mode 100644 src/ExportHistory/Models/ExportJobState.cs
create mode 100644 src/ExportHistory/Models/ExportJobStatus.cs
create mode 100644 src/ExportHistory/Models/ExportJobTransitions.cs
create mode 100644 src/ExportHistory/Models/ExportMode.cs
create mode 100644 src/ExportHistory/Options/ExportHistoryStorageOptions.cs
create mode 100644 src/ExportHistory/Orchestrations/ExecuteExportJobOperationOrchestrator.cs
create mode 100644 src/ExportHistory/Orchestrations/ExportJobOrchestrator.cs
diff --git a/samples/ExportHistoryWebApp/ExportHistoryWebApp.csproj b/samples/ExportHistoryWebApp/ExportHistoryWebApp.csproj
new file mode 100644
index 000000000..884da7e8b
--- /dev/null
+++ b/samples/ExportHistoryWebApp/ExportHistoryWebApp.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net6.0
+ enable
+ enable
+ true
+ $(BaseIntermediateOutputPath)Generated
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ExportHistoryWebApp/ExportHistoryWebApp.http b/samples/ExportHistoryWebApp/ExportHistoryWebApp.http
new file mode 100644
index 000000000..de24a7a52
--- /dev/null
+++ b/samples/ExportHistoryWebApp/ExportHistoryWebApp.http
@@ -0,0 +1,88 @@
+### Variables
+@baseUrl = http://localhost:5009
+@jobId = export-job-123
+
+### Create a new batch export job
+# @name createBatchExportJob
+POST {{baseUrl}}/export-jobs
+Content-Type: application/json
+
+{
+ "jobId": "{{jobId}}",
+ "mode": "Batch",
+ "createdTimeFrom": "2024-01-01T00:00:00Z",
+ "createdTimeTo": "2024-12-31T23:59:59Z",
+ "containerName": "export-history",
+ "prefix": "exports/",
+ "maxInstancesPerBatch": 100,
+ "runtimeStatus": ["Completed", "Failed"]
+}
+
+### Create a new continuous export job
+# @name createContinuousExportJob
+POST {{baseUrl}}/export-jobs
+Content-Type: application/json
+
+{
+ "jobId": "export-job-continuous-123",
+ "mode": "Continuous",
+ "createdTimeFrom": "2024-01-01T00:00:00Z",
+ "createdTimeTo": null,
+ "containerName": "export-history",
+ "prefix": "continuous-exports/",
+ "maxInstancesPerBatch": 50
+}
+
+### Create an export job with default storage (no container specified)
+# @name createExportJobWithDefaultStorage
+POST {{baseUrl}}/export-jobs
+Content-Type: application/json
+
+{
+ "jobId": "export-job-default-storage",
+ "mode": "Batch",
+ "createdTimeFrom": "2024-01-01T00:00:00Z",
+ "createdTimeTo": "2024-12-31T23:59:59Z",
+ "maxInstancesPerBatch": 100
+}
+
+### Get a specific export job by ID
+# Note: This endpoint can be used to verify the export job was created and check its status
+# The ID in the URL should match the jobId used in create request
+GET {{baseUrl}}/export-jobs/{{jobId}}
+
+### List all export jobs
+GET {{baseUrl}}/export-jobs/list
+
+### List export jobs with filters
+# Filter by status
+GET {{baseUrl}}/export-jobs/list?status=Active
+
+# Filter by job ID prefix
+GET {{baseUrl}}/export-jobs/list?jobIdPrefix=export-job-
+
+# Filter by creation time range
+GET {{baseUrl}}/export-jobs/list?createdFrom=2024-01-01T00:00:00Z&createdTo=2024-12-31T23:59:59Z
+
+# Combined filters
+GET {{baseUrl}}/export-jobs/list?status=Completed&jobIdPrefix=export-job-&pageSize=50
+
+### Delete an export job
+DELETE {{baseUrl}}/export-jobs/{{jobId}}
+
+### Tips:
+# - Replace the baseUrl variable if your application runs on a different port
+# - The jobId variable can be changed to test different export job instances
+# - Export modes:
+# - "Batch": Exports all instances within a time range (requires createdTimeTo)
+# - "Continuous": Continuously exports instances from a start time (createdTimeTo must be null)
+# - Runtime status filters (valid values):
+# - "Completed": Exports only completed orchestrations
+# - "Failed": Exports only failed orchestrations
+# - "Terminated": Exports only terminated orchestrations
+# - "ContinuedAsNew": Exports only continued-as-new orchestrations
+# - Dates are in ISO 8601 format (YYYY-MM-DDThh:mm:ssZ)
+# - You can use the REST Client extension in VS Code to execute these requests
+# - The @name directive allows referencing the response in subsequent requests
+# - Export jobs run asynchronously; use GET to check the status after creation
+
diff --git a/samples/ExportHistoryWebApp/ExportJobController.cs b/samples/ExportHistoryWebApp/ExportJobController.cs
new file mode 100644
index 000000000..34c510b4d
--- /dev/null
+++ b/samples/ExportHistoryWebApp/ExportJobController.cs
@@ -0,0 +1,190 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.ExportHistory;
+using ExportHistoryWebApp.Models;
+
+namespace ExportHistoryWebApp.Controllers;
+
+///
+/// Controller for managing export history jobs through a REST API.
+/// Provides endpoints for creating, reading, listing, and deleting export jobs.
+///
+[ApiController]
+[Route("export-jobs")]
+public class ExportJobController : ControllerBase
+{
+ readonly ExportHistoryClient exportHistoryClient;
+ readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Client for managing export history jobs.
+ /// Logger for recording controller operations.
+ public ExportJobController(
+ ExportHistoryClient exportHistoryClient,
+ ILogger logger)
+ {
+ this.exportHistoryClient = exportHistoryClient ?? throw new ArgumentNullException(nameof(exportHistoryClient));
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Creates a new export job based on the provided configuration.
+ ///
+ /// The export job creation request.
+ /// The created export job description.
+ [HttpPost]
+ public async Task> CreateExportJob([FromBody] CreateExportJobRequest request)
+ {
+ if (request == null)
+ {
+ return this.BadRequest("createExportJobRequest cannot be null");
+ }
+
+ try
+ {
+ ExportDestination? destination = null;
+ if (!string.IsNullOrEmpty(request.ContainerName))
+ {
+ destination = new ExportDestination(request.ContainerName)
+ {
+ Prefix = request.Prefix,
+ };
+ }
+
+ ExportJobCreationOptions creationOptions = new ExportJobCreationOptions(
+ mode: request.Mode,
+ createdTimeFrom: request.CreatedTimeFrom,
+ createdTimeTo: request.CreatedTimeTo,
+ destination: destination,
+ jobId: request.JobId,
+ format: request.Format,
+ runtimeStatus: request.RuntimeStatus,
+ maxInstancesPerBatch: request.MaxInstancesPerBatch);
+
+ ExportHistoryJobClient jobClient = await this.exportHistoryClient.CreateJobAsync(creationOptions);
+ ExportJobDescription description = await jobClient.DescribeAsync();
+
+ this.logger.LogInformation("Created new export job with ID: {JobId}", description.JobId);
+
+ return this.CreatedAtAction(nameof(GetExportJob), new { id = description.JobId }, description);
+ }
+ catch (ArgumentException ex)
+ {
+ this.logger.LogError(ex, "Validation failed while creating export job {JobId}", request.JobId);
+ return this.BadRequest(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Error creating export job {JobId}", request.JobId);
+ return this.StatusCode(500, "An error occurred while creating the export job");
+ }
+ }
+
+ ///
+ /// Retrieves a specific export job by its ID.
+ ///
+ /// The ID of the export job to retrieve.
+ /// The export job description if found.
+ [HttpGet("{id}")]
+ public async Task> GetExportJob(string id)
+ {
+ try
+ {
+ ExportJobDescription? job = await this.exportHistoryClient.GetJobAsync(id);
+ return this.Ok(job);
+ }
+ catch (ExportJobNotFoundException)
+ {
+ return this.NotFound();
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Error retrieving export job {JobId}", id);
+ return this.StatusCode(500, "An error occurred while retrieving the export job");
+ }
+ }
+
+ ///
+ /// Lists all export jobs, optionally filtered by query parameters.
+ ///
+ /// Optional filter by job status.
+ /// Optional filter by job ID prefix.
+ /// Optional filter for jobs created after this time.
+ /// Optional filter for jobs created before this time.
+ /// Optional page size for pagination.
+ /// Optional continuation token for pagination.
+ /// A collection of export job descriptions.
+ [HttpGet("list")]
+ public async Task>> ListExportJobs(
+ [FromQuery] ExportJobStatus? status = null,
+ [FromQuery] string? jobIdPrefix = null,
+ [FromQuery] DateTimeOffset? createdFrom = null,
+ [FromQuery] DateTimeOffset? createdTo = null,
+ [FromQuery] int? pageSize = null,
+ [FromQuery] string? continuationToken = null)
+ {
+ try
+ {
+ ExportJobQuery? query = null;
+ if (status.HasValue || !string.IsNullOrEmpty(jobIdPrefix) || createdFrom.HasValue || createdTo.HasValue || pageSize.HasValue || !string.IsNullOrEmpty(continuationToken))
+ {
+ query = new ExportJobQuery
+ {
+ Status = status,
+ JobIdPrefix = jobIdPrefix,
+ CreatedFrom = createdFrom,
+ CreatedTo = createdTo,
+ PageSize = pageSize,
+ ContinuationToken = continuationToken,
+ };
+ }
+
+ AsyncPageable jobs = this.exportHistoryClient.ListJobsAsync(query);
+
+ // Collect all jobs from the async pageable
+ List jobList = new List();
+ await foreach (ExportJobDescription job in jobs)
+ {
+ jobList.Add(job);
+ }
+
+ return this.Ok(jobList);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Error retrieving export jobs");
+ return this.StatusCode(500, "An error occurred while retrieving export jobs");
+ }
+ }
+
+ ///
+ /// Deletes an export job by its ID.
+ ///
+ /// The ID of the export job to delete.
+ /// No content if successful.
+ [HttpDelete("{id}")]
+ public async Task DeleteExportJob(string id)
+ {
+ try
+ {
+ ExportHistoryJobClient jobClient = this.exportHistoryClient.GetJobClient(id);
+ await jobClient.DeleteAsync();
+ return this.NoContent();
+ }
+ catch (ExportJobNotFoundException)
+ {
+ return this.NotFound();
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Error deleting export job {JobId}", id);
+ return this.StatusCode(500, "An error occurred while deleting the export job");
+ }
+ }
+}
+
diff --git a/samples/ExportHistoryWebApp/Models/CreateExportJobRequest.cs b/samples/ExportHistoryWebApp/Models/CreateExportJobRequest.cs
new file mode 100644
index 000000000..7863281c7
--- /dev/null
+++ b/samples/ExportHistoryWebApp/Models/CreateExportJobRequest.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.ExportHistory;
+
+namespace ExportHistoryWebApp.Models;
+
+///
+/// Represents a request to create a new export job.
+///
+public class CreateExportJobRequest
+{
+ ///
+ /// Gets or sets the unique identifier for the export job. If not provided, a GUID will be generated.
+ ///
+ public string? JobId { get; set; }
+
+ ///
+ /// Gets or sets the export mode (Batch or Continuous).
+ ///
+ public ExportMode Mode { get; set; }
+
+ ///
+ /// Gets or sets the start time for the export (inclusive). Required.
+ ///
+ public DateTimeOffset CreatedTimeFrom { get; set; }
+
+ ///
+ /// Gets or sets the end time for the export (inclusive). Required for Batch mode, null for Continuous mode.
+ ///
+ public DateTimeOffset? CreatedTimeTo { get; set; }
+
+ ///
+ /// Gets or sets the blob container name where exported data will be stored. Optional if default storage is configured.
+ ///
+ public string? ContainerName { get; set; }
+
+ ///
+ /// Gets or sets an optional prefix for blob paths.
+ ///
+ public string? Prefix { get; set; }
+
+ ///
+ /// Gets or sets the export format settings. Optional, defaults to jsonl-gzip.
+ ///
+ public ExportFormat? Format { get; set; }
+
+ ///
+ /// Gets or sets the orchestration runtime statuses to filter by. Optional.
+ /// Valid statuses are: Completed, Failed, Terminated, and ContinuedAsNew.
+ ///
+ public List? RuntimeStatus { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of instances to fetch per batch. Optional, defaults to 100.
+ ///
+ public int? MaxInstancesPerBatch { get; set; }
+}
+
diff --git a/samples/ExportHistoryWebApp/Program.cs b/samples/ExportHistoryWebApp/Program.cs
new file mode 100644
index 000000000..46ee02f41
--- /dev/null
+++ b/samples/ExportHistoryWebApp/Program.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.AzureManaged;
+using Microsoft.DurableTask.ExportHistory;
+using Microsoft.DurableTask.Worker;
+using Microsoft.DurableTask.Worker.AzureManaged;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+string connectionString = builder.Configuration.GetValue("DURABLE_TASK_CONNECTION_STRING")
+ ?? throw new InvalidOperationException("Missing required configuration 'DURABLE_TASK_CONNECTION_STRING'");
+
+string storageConnectionString = builder.Configuration.GetValue("EXPORT_HISTORY_STORAGE_CONNECTION_STRING")
+ ?? throw new InvalidOperationException("Missing required configuration 'EXPORT_HISTORY_STORAGE_CONNECTION_STRING'");
+
+string containerName = builder.Configuration.GetValue("EXPORT_HISTORY_CONTAINER_NAME")
+ ?? throw new InvalidOperationException("Missing required configuration 'EXPORT_HISTORY_CONTAINER_NAME'");
+
+builder.Services.AddSingleton(sp => sp.GetRequiredService().CreateLogger());
+builder.Services.AddLogging();
+
+// Add Durable Task worker with export history support
+builder.Services.AddDurableTaskWorker(builder =>
+{
+ builder.UseDurableTaskScheduler(connectionString);
+ builder.UseExportHistory();
+});
+
+// Register the client with export history support
+builder.Services.AddDurableTaskClient(clientBuilder =>
+{
+ clientBuilder.UseDurableTaskScheduler(connectionString);
+ clientBuilder.UseExportHistory(options =>
+ {
+ options.ConnectionString = storageConnectionString;
+ options.ContainerName = containerName;
+ options.Prefix = builder.Configuration.GetValue("EXPORT_HISTORY_PREFIX");
+ });
+});
+
+// Configure the HTTP request pipeline
+builder.Services.AddControllers().AddJsonOptions(options =>
+{
+ options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
+ options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+});
+
+// The actual listen URL can be configured in environment variables named "ASPNETCORE_URLS" or "ASPNETCORE_URLS_HTTPS"
+WebApplication app = builder.Build();
+app.MapControllers();
+app.Run();
+
diff --git a/samples/ExportHistoryWebApp/Properties/launchSettings.json b/samples/ExportHistoryWebApp/Properties/launchSettings.json
new file mode 100644
index 000000000..4375d42ec
--- /dev/null
+++ b/samples/ExportHistoryWebApp/Properties/launchSettings.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:47698",
+ "sslPort": 44372
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "applicationUrl": "http://localhost:5009",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DURABLE_TASK_CONNECTION_STRING": "",
+ "EXPORT_HISTORY_STORAGE_CONNECTION_STRING": "",
+ "EXPORT_HISTORY_CONTAINER_NAME": "export-history",
+ "EXPORT_HISTORY_PREFIX": ""
+ }
+ }
+ }
+}
+
diff --git a/samples/ExportHistoryWebApp/appsettings.Development.json b/samples/ExportHistoryWebApp/appsettings.Development.json
new file mode 100644
index 000000000..21b22dbb4
--- /dev/null
+++ b/samples/ExportHistoryWebApp/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
+
diff --git a/samples/ExportHistoryWebApp/appsettings.json b/samples/ExportHistoryWebApp/appsettings.json
new file mode 100644
index 000000000..fb8785032
--- /dev/null
+++ b/samples/ExportHistoryWebApp/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
+
diff --git a/src/Client/Grpc/GrpcDurableTaskClient.cs b/src/Client/Grpc/GrpcDurableTaskClient.cs
index c57b22f96..101901074 100644
--- a/src/Client/Grpc/GrpcDurableTaskClient.cs
+++ b/src/Client/Grpc/GrpcDurableTaskClient.cs
@@ -323,6 +323,56 @@ public override async Task WaitForInstanceStartAsync(
}
}
+ ///
+ /// Lists terminal orchestration instances sorted by completed timestamp, returning only instance IDs.
+ ///
+ /// Creation date of instances to query from.
+ /// Creation date of instances to query to.
+ /// Runtime statuses of instances to query (should be terminal statuses).
+ /// Maximum number of instance IDs to return per page.
+ /// The continuation token (instanceId) to continue from.
+ /// The cancellation token.
+ /// A tuple containing the list of instance IDs and the continuation token for the next page.
+ public async Task<(IReadOnlyList InstanceIds, string? ContinuationToken)> ListTerminalInstancesAsync(
+ DateTimeOffset? createdFrom = null,
+ DateTimeOffset? createdTo = null,
+ IEnumerable? statuses = null,
+ int pageSize = 100,
+ string? continuationToken = null,
+ CancellationToken cancellation = default)
+ {
+ Check.NotEntity(this.options.EnableEntitySupport, continuationToken);
+
+ P.ListTerminalInstancesRequest request = new()
+ {
+ Query = new P.TerminalInstanceQuery
+ {
+ CreatedTimeFrom = createdFrom?.ToTimestamp(),
+ CreatedTimeTo = createdTo?.ToTimestamp(),
+ MaxInstanceCount = pageSize,
+ ContinuationToken = continuationToken,
+ },
+ };
+
+ if (statuses is not null)
+ {
+ request.Query.RuntimeStatus.AddRange(statuses.Select(x => x.ToGrpcStatus()));
+ }
+
+ try
+ {
+ P.ListTerminalInstancesResponse response = await this.sidecarClient.ListTerminalInstancesAsync(
+ request, cancellationToken: cancellation);
+
+ return (response.InstanceIds.ToList(), response.ContinuationToken);
+ }
+ catch (RpcException e) when (e.StatusCode == StatusCode.Cancelled)
+ {
+ throw new OperationCanceledException(
+ $"The {nameof(this.ListTerminalInstancesAsync)} operation was canceled.", e, cancellation);
+ }
+ }
+
///
public override async Task WaitForInstanceCompletionAsync(
string instanceId, bool getInputsAndOutputs = false, CancellationToken cancellation = default)
diff --git a/src/ExportHistory/Activities/ExportInstanceHistoryActivity.cs b/src/ExportHistory/Activities/ExportInstanceHistoryActivity.cs
new file mode 100644
index 000000000..2a34c0508
--- /dev/null
+++ b/src/ExportHistory/Activities/ExportInstanceHistoryActivity.cs
@@ -0,0 +1,291 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Azure.Storage.Blobs;
+using Azure.Storage.Blobs.Models;
+using Microsoft.DurableTask.Client;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Activity that exports one orchestration instance history to the configured blob destination.
+///
+[DurableTask]
+public class ExportInstanceHistoryActivity : TaskActivity
+{
+ readonly IDurableTaskClientProvider clientProvider;
+ readonly ILogger logger;
+ readonly ExportHistoryStorageOptions storageOptions;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ExportInstanceHistoryActivity(
+ IDurableTaskClientProvider clientProvider,
+ ILogger logger,
+ IOptions storageOptions)
+ {
+ this.clientProvider = Check.NotNull(clientProvider, nameof(clientProvider));
+ this.logger = Check.NotNull(logger, nameof(logger));
+ this.storageOptions = Check.NotNull(storageOptions?.Value, nameof(storageOptions));
+ }
+
+ ///
+ public override async Task RunAsync(TaskActivityContext context, ExportRequest input)
+ {
+ Check.NotNull(input, nameof(input));
+ Check.NotNullOrEmpty(input.InstanceId, nameof(input.InstanceId));
+ Check.NotNull(input.Destination, nameof(input.Destination));
+ Check.NotNull(input.Format, nameof(input.Format));
+
+ string instanceId = input.InstanceId;
+
+ try
+ {
+ this.logger.LogInformation("Starting export for instance {InstanceId}", instanceId);
+
+ // Get the client and instance metadata with inputs and outputs
+ DurableTaskClient client = this.clientProvider.GetClient();
+ OrchestrationMetadata? metadata = await client.GetInstanceAsync(
+ instanceId,
+ getInputsAndOutputs: true,
+ cancellation: CancellationToken.None);
+
+ if (metadata == null)
+ {
+ string error = $"Instance {instanceId} not found";
+ this.logger.LogWarning(error);
+ return new ExportResult
+ {
+ InstanceId = instanceId,
+ Success = false,
+ Error = error,
+ };
+ }
+
+ // Get completed timestamp (LastUpdatedAt for terminal states)
+ DateTimeOffset completedTimestamp = metadata.LastUpdatedAt;
+ if (!metadata.IsCompleted)
+ {
+ string error = $"Instance {instanceId} is not in a completed state";
+ this.logger.LogWarning(error);
+ return new ExportResult
+ {
+ InstanceId = instanceId,
+ Success = false,
+ Error = error,
+ };
+ }
+
+ // Create blob filename from hash of completed timestamp and instance ID
+ string blobFileName = GenerateBlobFileName(completedTimestamp, instanceId, input.Format);
+
+ // Build blob path with prefix if provided
+ string blobPath = string.IsNullOrEmpty(input.Destination.Prefix)
+ ? blobFileName
+ : $"{input.Destination.Prefix.TrimEnd('/')}/{blobFileName}";
+
+ // Serialize instance metadata to JSON
+ string jsonContent = SerializeInstanceMetadata(metadata);
+
+ // Upload to blob storage
+ await UploadToBlobStorageAsync(
+ input.Destination.Container,
+ blobPath,
+ jsonContent,
+ input.Format,
+ CancellationToken.None);
+
+ this.logger.LogInformation(
+ "Successfully exported instance {InstanceId} to blob {BlobPath}",
+ instanceId,
+ blobPath);
+
+ return new ExportResult
+ {
+ InstanceId = instanceId,
+ Success = true,
+ };
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "Failed to export instance {InstanceId}", instanceId);
+ return new ExportResult
+ {
+ InstanceId = instanceId,
+ Success = false,
+ Error = ex.Message,
+ };
+ }
+ }
+
+ static string GenerateBlobFileName(DateTimeOffset completedTimestamp, string instanceId, ExportFormat format)
+ {
+ // Create hash from completed timestamp and instance ID
+ string hashInput = $"{completedTimestamp:O}|{instanceId}";
+ byte[] hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(hashInput));
+ string hash = Convert.ToHexString(hashBytes).ToLowerInvariant();
+
+ // Build filename with format extension
+ string extension = GetFileExtension(format);
+
+ return $"{hash}.{extension}";
+ }
+
+ ///
+ /// Gets the file extension for a given export format.
+ ///
+ /// The export format.
+ /// The file extension (e.g., "json", "jsonl.gz").
+ static string GetFileExtension(ExportFormat format)
+ {
+ string formatKind = format.Kind.ToLowerInvariant();
+
+ return formatKind switch
+ {
+ "jsonl" => "jsonl.gz", // JSONL format is compressed
+ "json" => "json", // JSON format is uncompressed
+ _ => "jsonl.gz", // Default to JSONL compressed
+ };
+ }
+
+ static string SerializeInstanceMetadata(OrchestrationMetadata metadata)
+ {
+ var exportData = new
+ {
+ instanceId = metadata.InstanceId,
+ name = metadata.Name,
+ runtimeStatus = metadata.RuntimeStatus.ToString(),
+ createdAt = metadata.CreatedAt,
+ lastUpdatedAt = metadata.LastUpdatedAt,
+ input = metadata.SerializedInput,
+ output = metadata.SerializedOutput,
+ customStatus = metadata.SerializedCustomStatus,
+ tags = metadata.Tags,
+ failureDetails = metadata.FailureDetails != null ? new
+ {
+ errorType = metadata.FailureDetails.ErrorType,
+ errorMessage = metadata.FailureDetails.ErrorMessage,
+ stackTrace = metadata.FailureDetails.StackTrace,
+ } : null,
+ };
+
+ return JsonSerializer.Serialize(exportData, new JsonSerializerOptions
+ {
+ WriteIndented = false,
+ });
+ }
+
+ async Task UploadToBlobStorageAsync(
+ string containerName,
+ string blobPath,
+ string content,
+ ExportFormat format,
+ CancellationToken cancellationToken)
+ {
+ // Create blob service client from connection string
+ BlobServiceClient serviceClient = new(this.storageOptions.ConnectionString);
+ BlobContainerClient containerClient = serviceClient.GetBlobContainerClient(containerName);
+
+ // Ensure container exists
+ await containerClient.CreateIfNotExistsAsync(
+ PublicAccessType.None,
+ cancellationToken: cancellationToken);
+
+ // Get blob client
+ BlobClient blobClient = containerClient.GetBlobClient(blobPath);
+
+ // Upload content
+ byte[] contentBytes = Encoding.UTF8.GetBytes(content);
+
+ if (format.Kind.ToLowerInvariant() == "jsonl")
+ {
+ // Compress with gzip
+ using MemoryStream compressedStream = new();
+ using (GZipStream gzipStream = new(compressedStream, CompressionLevel.Optimal, leaveOpen: true))
+ {
+ await gzipStream.WriteAsync(contentBytes, cancellationToken);
+ await gzipStream.FlushAsync(cancellationToken);
+ }
+
+ compressedStream.Position = 0;
+
+ BlobUploadOptions uploadOptions = new()
+ {
+ HttpHeaders = new BlobHttpHeaders
+ {
+ ContentType = "application/jsonl+gzip",
+ ContentEncoding = "gzip",
+ },
+ };
+
+ await blobClient.UploadAsync(compressedStream, uploadOptions, cancellationToken);
+ }
+ else
+ {
+ // Upload uncompressed
+ BlobUploadOptions uploadOptions = new()
+ {
+ HttpHeaders = new BlobHttpHeaders
+ {
+ ContentType = "application/json",
+ },
+ };
+
+ await blobClient.UploadAsync(
+ new BinaryData(contentBytes),
+ uploadOptions,
+ cancellationToken);
+ }
+ }
+}
+
+///
+/// Export request for one orchestration instance.
+///
+public sealed class ExportRequest
+{
+ ///
+ /// Gets or sets the instance ID to export.
+ ///
+ public string InstanceId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the export destination configuration.
+ ///
+ public ExportDestination Destination { get; set; } = null!;
+
+ ///
+ /// Gets or sets the export format configuration.
+ ///
+ public ExportFormat Format { get; set; } = null!;
+}
+
+///
+/// Export result.
+///
+public sealed class ExportResult
+{
+ ///
+ /// Gets or sets the instance ID that was exported.
+ ///
+ public string InstanceId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets whether the export was successful.
+ ///
+ public bool Success { get; set; }
+
+ ///
+ /// Gets or sets the error message if the export failed.
+ ///
+ public string? Error { get; set; }
+}
+
+
diff --git a/src/ExportHistory/Activities/ListTerminalInstancesActivity.cs b/src/ExportHistory/Activities/ListTerminalInstancesActivity.cs
new file mode 100644
index 000000000..facc804b1
--- /dev/null
+++ b/src/ExportHistory/Activities/ListTerminalInstancesActivity.cs
@@ -0,0 +1,106 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.Grpc;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Input for listing terminal instances activity.
+///
+public sealed record ListTerminalInstancesRequest(
+ DateTimeOffset CreatedTimeFrom,
+ DateTimeOffset? CreatedTimeTo,
+ IEnumerable? RuntimeStatus,
+ string? ContinuationToken,
+ int MaxInstancesPerBatch = 100);
+
+///
+/// Activity that lists terminal orchestration instances using the configured filters and checkpoint.
+///
+[DurableTask]
+public class ListTerminalInstancesActivity : TaskActivity
+{
+ readonly IDurableTaskClientProvider clientProvider;
+ readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ListTerminalInstancesActivity(
+ IDurableTaskClientProvider clientProvider,
+ ILogger logger)
+ {
+ this.clientProvider = Check.NotNull(clientProvider, nameof(clientProvider));
+ this.logger = Check.NotNull(logger, nameof(logger));
+ }
+
+ ///
+ public override async Task RunAsync(TaskActivityContext context, ListTerminalInstancesRequest request)
+ {
+ Check.NotNull(request, nameof(request));
+
+ try
+ {
+ DurableTaskClient client = this.clientProvider.GetClient();
+
+ // Check if the client is a gRPC client that supports ListTerminalInstances
+ if (client is not GrpcDurableTaskClient grpcClient)
+ {
+ throw new NotSupportedException(
+ $"ListTerminalInstancesActivity requires a GrpcDurableTaskClient, but got {client.GetType().Name}");
+ }
+
+ // Call the gRPC endpoint to list terminal instances
+ (IReadOnlyList instanceIds, string? nextContinuationToken) = await grpcClient.ListTerminalInstancesAsync(
+ createdFrom: request.CreatedTimeFrom,
+ createdTo: request.CreatedTimeTo,
+ statuses: request.RuntimeStatus,
+ pageSize: request.MaxInstancesPerBatch,
+ continuationToken: request.ContinuationToken,
+ cancellation: CancellationToken.None);
+
+ this.logger.LogInformation(
+ "ListTerminalInstancesActivity returned {Count} instance IDs",
+ instanceIds.Count);
+
+ // Create next checkpoint if we have a continuation token
+ ExportCheckpoint? nextCheckpoint = null;
+ if (!string.IsNullOrEmpty(nextContinuationToken) && instanceIds.Count > 0)
+ {
+ // Use the continuation token from the response (which is the last instanceId)
+ string lastInstanceId = nextContinuationToken;
+ nextCheckpoint = new ExportCheckpoint
+ {
+ ContinuationToken = lastInstanceId,
+ LastInstanceIdProcessed = lastInstanceId,
+ // Note: LastTerminalTimeProcessed would require querying the instance, which we skip for performance
+ };
+ }
+
+ return new InstancePage
+ {
+ InstanceIds = instanceIds.ToList(),
+ NextCheckpoint = nextCheckpoint,
+ };
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogError(ex, "ListTerminalInstancesActivity failed");
+ throw;
+ }
+ }
+}
+
+///
+/// A page of instances for export.
+///
+public sealed class InstancePage
+{
+ public List InstanceIds { get; set; } = new();
+ public ExportCheckpoint? NextCheckpoint { get; set; }
+}
+
+
diff --git a/src/ExportHistory/Client/DefaultExportHistoryClient.cs b/src/ExportHistory/Client/DefaultExportHistoryClient.cs
new file mode 100644
index 000000000..9dcd145d4
--- /dev/null
+++ b/src/ExportHistory/Client/DefaultExportHistoryClient.cs
@@ -0,0 +1,192 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.Entities;
+using Microsoft.DurableTask.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Convenience client for managing export jobs via entity signals and reads.
+///
+public sealed class DefaultExportHistoryClient(
+ DurableTaskClient durableTaskClient,
+ ILogger logger,
+ ExportHistoryStorageOptions storageOptions) : ExportHistoryClient
+{
+ readonly DurableTaskClient durableTaskClient = Check.NotNull(durableTaskClient, nameof(durableTaskClient));
+ readonly ILogger logger = Check.NotNull(logger, nameof(logger));
+ readonly ExportHistoryStorageOptions storageOptions = Check.NotNull(storageOptions, nameof(storageOptions));
+
+ ///
+ public override async Task CreateJobAsync(
+ ExportJobCreationOptions options,
+ CancellationToken cancellation = default)
+ {
+ Check.NotNull(options, nameof(options));
+ this.logger.ClientCreatingExportJob(options);
+
+ try
+ {
+ // Create export job client instance
+ ExportHistoryJobClient exportHistoryJobClient = new DefaultExportHistoryJobClient(
+ this.durableTaskClient,
+ options.JobId,
+ this.logger,
+ this.storageOptions
+ );
+
+ // Create the export job using the client (validation already done in constructor)
+ await exportHistoryJobClient.CreateAsync(options, cancellation);
+
+ // Return the job client
+ return exportHistoryJobClient;
+ }
+ catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
+ {
+ // the operation was cancelled as requested. No need to log this.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ this.logger.ClientError(nameof(this.CreateJobAsync), options.JobId, ex);
+
+ throw;
+ }
+ }
+
+ ///
+ public override async Task GetJobAsync(string jobId, CancellationToken cancellation = default)
+ {
+ Check.NotNullOrEmpty(jobId, nameof(jobId));
+
+ try
+ {
+ // Get export history job client first
+ ExportHistoryJobClient exportHistoryJobClient = this.GetJobClient(jobId);
+
+ // Call DescribeAsync which handles all the entity state mapping
+ return await exportHistoryJobClient.DescribeAsync(cancellation);
+ }
+ catch (ExportJobNotFoundException)
+ {
+ // Re-throw as the job not being found is an error condition
+ throw;
+ }
+ catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
+ {
+ // the operation was cancelled as requested. No need to log this.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ this.logger.ClientError(nameof(this.GetJobAsync), jobId, ex);
+
+ throw;
+ }
+ }
+
+ ///
+ public override AsyncPageable ListJobsAsync(ExportJobQuery? filter = null)
+ {
+ // TODO: revisit the fields
+ // Create an async pageable using the Pageable.Create helper
+ return Pageable.Create(async (continuationToken, pageSize, cancellation) =>
+ {
+ try
+ {
+ EntityQuery query = new EntityQuery
+ {
+ InstanceIdStartsWith = $"@{nameof(ExportJob)}@{filter?.JobIdPrefix ?? string.Empty}",
+ IncludeState = true,
+ PageSize = filter?.PageSize ?? ExportJobQuery.DefaultPageSize,
+ ContinuationToken = continuationToken,
+ };
+
+ // Get one page of entities
+ IAsyncEnumerable>> entityPages =
+ this.durableTaskClient.Entities.GetAllEntitiesAsync(query).AsPages();
+
+ await foreach (Page> entityPage in entityPages)
+ {
+ List exportJobs = new();
+
+ foreach (EntityMetadata metadata in entityPage.Values)
+ {
+ if (filter != null && !MatchesFilter(metadata.State, filter))
+ {
+ continue;
+ }
+
+ ExportJobState state = metadata.State;
+ ExportJobConfiguration? config = state.Config;
+
+ // Determine orchestrator presence for this job
+ string orchestratorInstanceId = ExportHistoryConstants.GetOrchestratorInstanceId(metadata.Id.Key);
+ OrchestrationMetadata? orchestratorState = await this.durableTaskClient.GetInstanceAsync(orchestratorInstanceId, cancellation: cancellation);
+ string? presentOrchestratorId = orchestratorState != null ? orchestratorInstanceId : null;
+
+ exportJobs.Add(new ExportJobDescription
+ {
+ JobId = metadata.Id.Key,
+ Status = state.Status,
+ CreatedAt = state.CreatedAt,
+ LastModifiedAt = state.LastModifiedAt,
+ Config = config,
+ OrchestratorInstanceId = presentOrchestratorId,
+ });
+ }
+
+ return new Page(exportJobs, entityPage.ContinuationToken);
+ }
+
+ // Return empty page if no results
+ return new Page(new List(), null);
+ }
+ catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
+ {
+ // the operation was cancelled as requested. No need to log this.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ this.logger.ClientError(nameof(this.ListJobsAsync), string.Empty, ex);
+
+ throw;
+ }
+ });
+ }
+
+ ///
+ /// Gets a job client for the specified job ID.
+ ///
+ /// The job ID.
+ /// The export history job client.
+ public override ExportHistoryJobClient GetJobClient(string jobId)
+ {
+ return new DefaultExportHistoryJobClient(
+ this.durableTaskClient,
+ jobId,
+ this.logger,
+ this.storageOptions);
+ }
+
+ ///
+ /// Checks if an export job state matches the provided filter criteria.
+ ///
+ /// The export job state to check.
+ /// The filter criteria.
+ /// True if the state matches the filter; otherwise, false.
+ static bool MatchesFilter(ExportJobState state, ExportJobQuery filter)
+ {
+ bool statusMatches = !filter.Status.HasValue || state.Status == filter.Status.Value;
+ bool createdFromMatches = !filter.CreatedFrom.HasValue ||
+ (state.CreatedAt.HasValue && state.CreatedAt.Value > filter.CreatedFrom.Value);
+ bool createdToMatches = !filter.CreatedTo.HasValue ||
+ (state.CreatedAt.HasValue && state.CreatedAt.Value < filter.CreatedTo.Value);
+
+ return statusMatches && createdFromMatches && createdToMatches;
+ }
+}
\ No newline at end of file
diff --git a/src/ExportHistory/Client/DefaultExportHistoryJobClient.cs b/src/ExportHistory/Client/DefaultExportHistoryJobClient.cs
new file mode 100644
index 000000000..7f7764e56
--- /dev/null
+++ b/src/ExportHistory/Client/DefaultExportHistoryJobClient.cs
@@ -0,0 +1,299 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.Entities;
+using Microsoft.DurableTask.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Convenience client for managing export jobs via entity signals and reads.
+///
+public sealed class DefaultExportHistoryJobClient(
+ DurableTaskClient durableTaskClient,
+ string jobId,
+ ILogger logger,
+ ExportHistoryStorageOptions storageOptions
+) : ExportHistoryJobClient(jobId)
+{
+ readonly DurableTaskClient durableTaskClient = Check.NotNull(durableTaskClient, nameof(durableTaskClient));
+ readonly ILogger logger = Check.NotNull(logger, nameof(logger));
+ readonly ExportHistoryStorageOptions storageOptions = Check.NotNull(storageOptions, nameof(storageOptions));
+ readonly EntityInstanceId entityId = new(nameof(ExportJob), jobId);
+
+ public override async Task CreateAsync(ExportJobCreationOptions options, CancellationToken cancellation = default)
+ {
+ try
+ {
+ Check.NotNull(options, nameof(options));
+
+ // If destination is not provided, construct it from storage options
+ ExportJobCreationOptions optionsWithDestination = options;
+ if (options.Destination == null)
+ {
+ ExportDestination destination = new ExportDestination(this.storageOptions.ContainerName)
+ {
+ Prefix = this.storageOptions.Prefix,
+ };
+
+ optionsWithDestination = options with { Destination = destination };
+ }
+
+ ExportJobOperationRequest request =
+ new ExportJobOperationRequest(
+ this.entityId,
+ nameof(ExportJob.Create),
+ optionsWithDestination);
+
+ string instanceId = await this.durableTaskClient
+ .ScheduleNewOrchestrationInstanceAsync(
+ new TaskName(nameof(ExecuteExportJobOperationOrchestrator)),
+ request,
+ cancellation);
+
+ // Wait for the orchestration to complete
+ OrchestrationMetadata state = await this.durableTaskClient
+ .WaitForInstanceCompletionAsync(
+ instanceId,
+ true,
+ cancellation);
+
+ if (state.RuntimeStatus != OrchestrationRuntimeStatus.Completed)
+ {
+ throw new InvalidOperationException(
+ $"Failed to create export job '{this.JobId}': " +
+ $"{state.FailureDetails?.ErrorMessage ?? string.Empty}");
+ }
+ }
+ catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
+ {
+ // the operation was cancelled as requested. No need to log this.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ this.logger.ClientError(nameof(this.CreateAsync), this.JobId, ex);
+
+ throw;
+ }
+ }
+
+ // TODO: there is no atomicity guarantee of deleting entity and purging the orchestrator
+ // Add sweeping process to clean up orphaned orchestrations failed to be purged
+ public override async Task DeleteAsync(CancellationToken cancellation = default)
+ {
+ try
+ {
+ this.logger.ClientDeletingExportJob(this.JobId);
+
+ string orchestrationInstanceId = ExportHistoryConstants.GetOrchestratorInstanceId(this.JobId);
+
+ // First, delete the entity
+ ExportJobOperationRequest request = new ExportJobOperationRequest(this.entityId, ExportJobOperations.Delete);
+ string instanceId = await this.durableTaskClient.ScheduleNewOrchestrationInstanceAsync(
+ new TaskName(nameof(ExecuteExportJobOperationOrchestrator)),
+ request,
+ cancellation);
+
+ // Wait for the orchestration to complete
+ OrchestrationMetadata state = await this.durableTaskClient.WaitForInstanceCompletionAsync(instanceId, true, cancellation);
+
+ if (state.RuntimeStatus != OrchestrationRuntimeStatus.Completed)
+ {
+ throw new InvalidOperationException($"Failed to delete export job '{this.JobId}': {state.FailureDetails?.ErrorMessage ?? string.Empty}");
+ }
+
+ // Then terminate the linked export orchestration if it exists
+ await this.TerminateAndPurgeOrchestrationAsync(orchestrationInstanceId, cancellation);
+
+ // Verify both entity and orchestration are gone
+ await this.VerifyDeletionAsync(orchestrationInstanceId, cancellation);
+ }
+ catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
+ {
+ // the operation was cancelled as requested. No need to log this.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ this.logger.ClientError(nameof(this.DeleteAsync), this.JobId, ex);
+
+ throw;
+ }
+ }
+
+ public override async Task DescribeAsync(CancellationToken cancellation = default)
+ {
+ try
+ {
+ Check.NotNullOrEmpty(this.JobId, nameof(this.JobId));
+
+ EntityMetadata? metadata =
+ await this.durableTaskClient.Entities.GetEntityAsync(this.entityId, cancellation: cancellation);
+ if (metadata == null)
+ {
+ throw new ExportJobNotFoundException(this.JobId);
+ }
+
+ ExportJobState state = metadata.State;
+
+ ExportJobConfiguration? config = state.Config;
+
+ // Determine if the export orchestrator instance exists and capture its instance ID if so
+ string orchestratorInstanceId = ExportHistoryConstants.GetOrchestratorInstanceId(this.JobId);
+ OrchestrationMetadata? orchestratorState = await this.durableTaskClient.GetInstanceAsync(orchestratorInstanceId, cancellation: cancellation);
+ string? presentOrchestratorId = orchestratorState != null ? orchestratorInstanceId : null;
+
+ return new ExportJobDescription
+ {
+ JobId = this.JobId,
+ Status = state.Status,
+ CreatedAt = state.CreatedAt,
+ LastModifiedAt = state.LastModifiedAt,
+ Config = config,
+ OrchestratorInstanceId = presentOrchestratorId,
+ };
+ }
+ catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
+ {
+ // the operation was cancelled as requested. No need to log this.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ this.logger.ClientError(nameof(this.DescribeAsync), this.JobId, ex);
+
+ throw;
+ }
+ }
+
+ ///
+ /// Terminates and purges the export orchestration instance.
+ ///
+ /// The orchestration instance ID to terminate and purge.
+ /// The cancellation token.
+ async Task TerminateAndPurgeOrchestrationAsync(string orchestrationInstanceId, CancellationToken cancellation)
+ {
+ try
+ {
+ // Terminate the orchestration (will fail silently if it doesn't exist or already terminated)
+ await this.durableTaskClient.TerminateInstanceAsync(
+ orchestrationInstanceId,
+ new TerminateInstanceOptions { Output = "Export job deleted" },
+ cancellation);
+
+ // Wait for the orchestration to be terminated before purging
+ OrchestrationMetadata? orchestrationState = await this.WaitForOrchestrationTerminationAsync(
+ orchestrationInstanceId,
+ cancellation);
+
+ // Purge the orchestration instance after it's terminated
+ if (orchestrationState != null && DefaultExportHistoryJobClient.IsTerminalStatus(orchestrationState.RuntimeStatus))
+ {
+ await this.durableTaskClient.PurgeInstanceAsync(
+ orchestrationInstanceId,
+ cancellation: cancellation);
+ }
+ else if (orchestrationState != null)
+ {
+ throw new InvalidOperationException(
+ $"Failed to delete export job '{this.JobId}': Cannot purge orchestration '{orchestrationInstanceId}' because it is still in '{orchestrationState.RuntimeStatus}' status.");
+ }
+ }
+ catch (Exception ex) when (!(ex is InvalidOperationException))
+ {
+ // Log but don't fail if termination fails (orchestration may not exist or already be terminated)
+ this.logger.ClientError(
+ $"Failed to terminate or purge linked orchestration '{orchestrationInstanceId}': {ex.Message}",
+ this.JobId,
+ ex);
+ // Continue to verification - if orchestration doesn't exist, verification will pass
+ }
+ }
+
+ ///
+ /// Waits for an orchestration to reach a terminal state.
+ ///
+ /// The orchestration instance ID.
+ /// The cancellation token.
+ /// The orchestration metadata, or null if the orchestration doesn't exist.
+ async Task WaitForOrchestrationTerminationAsync(
+ string orchestrationInstanceId,
+ CancellationToken cancellation)
+ {
+ OrchestrationMetadata? orchestrationState = null;
+ int waitAttempt = 0;
+
+ while (waitAttempt < ExportHistoryConstants.MaxTerminationWaitAttempts)
+ {
+ orchestrationState = await this.durableTaskClient.GetInstanceAsync(
+ orchestrationInstanceId,
+ cancellation: cancellation);
+
+ if (orchestrationState == null || DefaultExportHistoryJobClient.IsTerminalStatus(orchestrationState.RuntimeStatus))
+ {
+ break;
+ }
+
+ // Wait a bit before checking again
+ await Task.Delay(
+ TimeSpan.FromMilliseconds(ExportHistoryConstants.TerminationWaitDelayMs),
+ cancellation);
+ waitAttempt++;
+ }
+
+ return orchestrationState;
+ }
+
+ ///
+ /// Checks if an orchestration runtime status is a terminal state.
+ ///
+ /// The runtime status to check.
+ /// True if the status is terminal; otherwise, false.
+ static bool IsTerminalStatus(OrchestrationRuntimeStatus runtimeStatus)
+ {
+ return runtimeStatus == OrchestrationRuntimeStatus.Terminated ||
+ runtimeStatus == OrchestrationRuntimeStatus.Completed ||
+ runtimeStatus == OrchestrationRuntimeStatus.Failed;
+ }
+
+ ///
+ /// Verifies that both the entity and orchestration have been deleted.
+ ///
+ /// The orchestration instance ID to verify.
+ /// The cancellation token.
+ async Task VerifyDeletionAsync(string orchestrationInstanceId, CancellationToken cancellation)
+ {
+ List stillExist = new();
+
+ // Check if entity still exists
+ EntityMetadata? entityMetadata = await this.durableTaskClient.Entities.GetEntityAsync(
+ this.entityId,
+ cancellation: cancellation);
+ if (entityMetadata != null)
+ {
+ stillExist.Add($"entity '{this.entityId}'");
+ }
+
+ // Check if orchestration still exists
+ OrchestrationMetadata? orchestrationMetadata = await this.durableTaskClient.GetInstanceAsync(
+ orchestrationInstanceId,
+ cancellation: cancellation);
+ if (orchestrationMetadata != null)
+ {
+ stillExist.Add($"orchestration '{orchestrationInstanceId}'");
+ }
+
+ // Throw exception if either still exists
+ if (stillExist.Count > 0)
+ {
+ string items = string.Join(" and ", stillExist);
+ throw new InvalidOperationException(
+ $"Failed to delete export job '{this.JobId}': The following resources still exist: {items}.");
+ }
+ }
+}
+
+
diff --git a/src/ExportHistory/Client/ExportHistoryClient.cs b/src/ExportHistory/Client/ExportHistoryClient.cs
new file mode 100644
index 000000000..18c06feeb
--- /dev/null
+++ b/src/ExportHistory/Client/ExportHistoryClient.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Entities;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Convenience client for managing export jobs via entity signals and reads.
+///
+public abstract class ExportHistoryClient
+{
+ public abstract Task CreateJobAsync(ExportJobCreationOptions options, CancellationToken cancellation = default);
+ public abstract Task GetJobAsync(string jobId, CancellationToken cancellation = default);
+
+ public abstract AsyncPageable ListJobsAsync(ExportJobQuery? filter = null);
+ public abstract ExportHistoryJobClient GetJobClient(string jobId);
+}
\ No newline at end of file
diff --git a/src/ExportHistory/Client/ExportHistoryJobClient.cs b/src/ExportHistory/Client/ExportHistoryJobClient.cs
new file mode 100644
index 000000000..8d9994e9e
--- /dev/null
+++ b/src/ExportHistory/Client/ExportHistoryJobClient.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Entities;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Convenience client for managing export jobs via entity signals and reads.
+///
+public abstract class ExportHistoryJobClient
+{
+ public readonly string JobId;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ protected ExportHistoryJobClient(string jobId)
+ {
+ this.JobId = Check.NotNullOrEmpty(jobId, nameof(jobId));
+ }
+
+ public abstract Task CreateAsync(ExportJobCreationOptions options, CancellationToken cancellation = default);
+ public abstract Task DescribeAsync(CancellationToken cancellation = default);
+ public abstract Task DeleteAsync(CancellationToken cancellation = default);
+}
+
+
diff --git a/src/ExportHistory/Constants/ExportHistoryConstants.cs b/src/ExportHistory/Constants/ExportHistoryConstants.cs
new file mode 100644
index 000000000..85cf8f0d0
--- /dev/null
+++ b/src/ExportHistory/Constants/ExportHistoryConstants.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Constants used throughout the export history functionality.
+///
+static class ExportHistoryConstants
+{
+ ///
+ /// The prefix pattern used for generating export job orchestrator instance IDs.
+ /// Format: "ExportJob-{jobId}"
+ ///
+ public const string OrchestratorInstanceIdPrefix = "ExportJob-";
+
+ ///
+ /// Maximum number of attempts to wait for orchestration termination during deletion.
+ ///
+ public const int MaxTerminationWaitAttempts = 10;
+
+ ///
+ /// Delay between termination wait attempts in milliseconds.
+ ///
+ public const int TerminationWaitDelayMs = 100;
+
+ ///
+ /// Generates an orchestrator instance ID for a given export job ID.
+ ///
+ /// The export job ID.
+ /// The orchestrator instance ID.
+ public static string GetOrchestratorInstanceId(string jobId) => $"{OrchestratorInstanceIdPrefix}{jobId}";
+}
diff --git a/src/ExportHistory/Entity/ExportJob.cs b/src/ExportHistory/Entity/ExportJob.cs
new file mode 100644
index 000000000..e9f72241d
--- /dev/null
+++ b/src/ExportHistory/Entity/ExportJob.cs
@@ -0,0 +1,264 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Durable entity that manages a history export job: lifecycle, configuration, and progress.
+///
+/// The logger instance.
+class ExportJob(ILogger logger) : TaskEntity
+{
+ ///
+ /// Creates a new export job from creation options.
+ ///
+ /// The entity context.
+ /// The export job creation options.
+ /// Thrown when creationOptions is null.
+ /// Thrown when invalid state transition is attempted or export job already exists.
+ public void Create(TaskEntityContext context, ExportJobCreationOptions creationOptions)
+ {
+ try
+ {
+ Check.NotNull(creationOptions, nameof(creationOptions));
+
+ if (!this.CanTransitionTo(nameof(this.Create), ExportJobStatus.Active))
+ {
+ throw new ExportJobInvalidTransitionException(
+ creationOptions.JobId,
+ this.State.Status,
+ ExportJobStatus.Active,
+ nameof(this.Create));
+ }
+
+ // Convert ExportJobCreationOptions to ExportJobConfiguration
+ // Note: RuntimeStatus validation already done in ExportJobCreationOptions constructor
+ // Note: Destination should be populated by the client before reaching here
+ Verify.NotNull(creationOptions.Destination, nameof(creationOptions.Destination));
+
+ ExportJobConfiguration config = new ExportJobConfiguration(
+ Mode: creationOptions.Mode,
+ Filter: new ExportFilter(
+ CreatedTimeFrom: creationOptions.CreatedTimeFrom,
+ CreatedTimeTo: creationOptions.CreatedTimeTo,
+ RuntimeStatus: creationOptions.RuntimeStatus),
+ Destination: creationOptions.Destination,
+ Format: creationOptions.Format,
+ MaxInstancesPerBatch: creationOptions.MaxInstancesPerBatch);
+
+ this.State.Config = config;
+ this.State.Status = ExportJobStatus.Active;
+ this.State.CreatedAt = this.State.LastModifiedAt = DateTimeOffset.UtcNow;
+ this.State.LastError = null;
+
+ logger.CreatedExportJob(creationOptions.JobId);
+
+ // Signal the Run method to start the export
+ context.SignalEntity(
+ context.Id,
+ nameof(this.Run));
+ }
+ catch (Exception ex)
+ {
+ logger.ExportJobOperationError(
+ creationOptions?.JobId ?? string.Empty,
+ nameof(this.Create),
+ "Failed to create export job",
+ ex);
+ throw;
+ }
+ }
+
+ ///
+ /// Runs the export job by starting the export orchestrator.
+ ///
+ /// The entity context.
+ /// Thrown when export job is not in Active status.
+ public void Run(TaskEntityContext context)
+ {
+ try
+ {
+ Verify.NotNull(this.State.Config, nameof(this.State.Config));
+
+ if (this.State.Status != ExportJobStatus.Active)
+ {
+ string errorMessage = "Export job must be in Active status to run.";
+ logger.ExportJobOperationError(context.Id.Key, nameof(this.Run), errorMessage, new InvalidOperationException(errorMessage));
+ throw new InvalidOperationException(errorMessage);
+ }
+
+ this.StartExportOrchestration(context);
+ }
+ catch (Exception ex)
+ {
+ logger.ExportJobOperationError(
+ context.Id.Key,
+ nameof(this.Run),
+ "Failed to run export job",
+ ex);
+ throw;
+ }
+ }
+
+ void StartExportOrchestration(TaskEntityContext context)
+ {
+ try
+ {
+ // Use a fixed instance ID based on job ID to ensure only one orchestrator runs per job
+ // This prevents concurrent orchestrators if Run is called multiple times
+ string instanceId = ExportHistoryConstants.GetOrchestratorInstanceId(context.Id.Key);
+ StartOrchestrationOptions startOrchestrationOptions = new StartOrchestrationOptions(instanceId);
+
+ logger.ExportJobOperationInfo(
+ context.Id.Key,
+ nameof(this.StartExportOrchestration),
+ $"Starting new orchestration named '{nameof(ExportJobOrchestrator)}' with instance ID: {instanceId}");
+
+ context.ScheduleNewOrchestration(
+ new TaskName(nameof(ExportJobOrchestrator)),
+ new ExportJobRunRequest(context.Id),
+ startOrchestrationOptions);
+ }
+ catch (Exception ex)
+ {
+ // Mark job as failed and record the exception
+ this.State.Status = ExportJobStatus.Failed;
+ this.State.LastError = ex.Message;
+ this.State.LastModifiedAt = DateTimeOffset.UtcNow;
+
+ logger.ExportJobOperationError(
+ context.Id.Key,
+ nameof(this.StartExportOrchestration),
+ "Failed to start export orchestration",
+ ex);
+ }
+ }
+
+ bool CanTransitionTo(string operationName, ExportJobStatus targetStatus)
+ {
+ return ExportJobTransitions.IsValidTransition(operationName, this.State.Status, targetStatus);
+ }
+
+ ///
+ /// Commits a checkpoint snapshot with progress updates and optional failures.
+ ///
+ /// The entity context.
+ /// The checkpoint commit request containing progress, checkpoint, and failures.
+ public void CommitCheckpoint(TaskEntityContext context, CommitCheckpointRequest request)
+ {
+ Verify.NotNull(request, nameof(request));
+
+ // Update progress counts
+ this.State.ScannedInstances += request.ScannedInstances;
+ this.State.ExportedInstances += request.ExportedInstances;
+
+ // Update checkpoint if provided (successful batch moves cursor forward)
+ // If null (failed batch), keep current checkpoint to not move cursor forward
+ if (request.Checkpoint is not null)
+ {
+ this.State.Checkpoint = request.Checkpoint;
+ }
+
+ // Record failures if any
+ if (request.Failures != null && request.Failures.Count > 0)
+ {
+ foreach (ExportFailure failure in request.Failures)
+ {
+ this.State.FailedInstances[failure.InstanceId] = failure;
+ }
+ }
+
+ // Update checkpoint time and last modified time
+ this.State.LastCheckpointTime = DateTimeOffset.UtcNow;
+ this.State.LastModifiedAt = DateTimeOffset.UtcNow;
+
+ // If there are failures and checkpoint is null (batch failed), mark job as failed
+ if (request.Checkpoint is null && request.Failures != null && request.Failures.Count > 0)
+ {
+ this.State.Status = ExportJobStatus.Failed;
+ string failureSummary = string.Join("; ", request.Failures.Select(f => $"{f.InstanceId}: {f.Reason}"));
+ this.State.LastError = $"Batch export failed after retries. Failures: {failureSummary}";
+ }
+ }
+
+ ///
+ /// Marks the export job as completed.
+ ///
+ /// The entity context.
+ /// Thrown when invalid state transition is attempted.
+ public void MarkAsCompleted(TaskEntityContext context)
+ {
+ try
+ {
+ if (!this.CanTransitionTo(nameof(this.MarkAsCompleted), ExportJobStatus.Completed))
+ {
+ throw new ExportJobInvalidTransitionException(
+ context.Id.Key,
+ this.State.Status,
+ ExportJobStatus.Completed,
+ nameof(this.MarkAsCompleted));
+ }
+
+ this.State.Status = ExportJobStatus.Completed;
+ this.State.LastModifiedAt = DateTimeOffset.UtcNow;
+ this.State.LastError = null;
+
+ logger.ExportJobOperationInfo(
+ context.Id.Key,
+ nameof(this.MarkAsCompleted),
+ "Export job marked as completed");
+ }
+ catch (Exception ex)
+ {
+ logger.ExportJobOperationError(
+ context.Id.Key,
+ nameof(this.MarkAsCompleted),
+ "Failed to mark export job as completed",
+ ex);
+ throw;
+ }
+ }
+
+ ///
+ /// Marks the export job as failed.
+ ///
+ /// The entity context.
+ /// The error message describing why the job failed.
+ /// Thrown when invalid state transition is attempted.
+ public void MarkAsFailed(TaskEntityContext context, string? errorMessage = null)
+ {
+ try
+ {
+ if (!this.CanTransitionTo(nameof(this.MarkAsFailed), ExportJobStatus.Failed))
+ {
+ throw new ExportJobInvalidTransitionException(
+ context.Id.Key,
+ this.State.Status,
+ ExportJobStatus.Failed,
+ nameof(this.MarkAsFailed));
+ }
+
+ this.State.Status = ExportJobStatus.Failed;
+ this.State.LastError = errorMessage;
+ this.State.LastModifiedAt = DateTimeOffset.UtcNow;
+
+ logger.ExportJobOperationInfo(
+ context.Id.Key,
+ nameof(this.MarkAsFailed),
+ $"Export job marked as failed: {errorMessage ?? "Unknown error"}");
+ }
+ catch (Exception ex)
+ {
+ logger.ExportJobOperationError(
+ context.Id.Key,
+ nameof(this.MarkAsFailed),
+ "Failed to mark export job as failed",
+ ex);
+ throw;
+ }
+ }
+}
diff --git a/src/ExportHistory/Entity/ExportJobOperations.cs b/src/ExportHistory/Entity/ExportJobOperations.cs
new file mode 100644
index 000000000..c128b96fb
--- /dev/null
+++ b/src/ExportHistory/Entity/ExportJobOperations.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Constants for export job entity operation names.
+///
+static class ExportJobOperations
+{
+ ///
+ /// Operation name for getting entity state.
+ ///
+ public const string Get = "get";
+
+ ///
+ /// Operation name for deleting the entity.
+ ///
+ public const string Delete = "delete";
+}
+
diff --git a/src/ExportHistory/Exception/ExportJobClientValidationException.cs b/src/ExportHistory/Exception/ExportJobClientValidationException.cs
new file mode 100644
index 000000000..c2d34a174
--- /dev/null
+++ b/src/ExportHistory/Exception/ExportJobClientValidationException.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Exception thrown when client-side validation fails for export job operations.
+///
+public class ExportJobClientValidationException : InvalidOperationException
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ID of the export job that failed validation.
+ /// The validation error message.
+ public ExportJobClientValidationException(string jobId, string message)
+ : base($"Validation failed for export job '{jobId}': {message}")
+ {
+ this.JobId = jobId;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ID of the export job that failed validation.
+ /// The validation error message.
+ /// The exception that is the cause of the current exception.
+ public ExportJobClientValidationException(string jobId, string message, Exception innerException)
+ : base($"Validation failed for export job '{jobId}': {message}", innerException)
+ {
+ this.JobId = jobId;
+ }
+
+ ///
+ /// Gets the ID of the export job that failed validation.
+ ///
+ public string JobId { get; }
+}
+
diff --git a/src/ExportHistory/Exception/ExportJobInvalidTransitionException.cs b/src/ExportHistory/Exception/ExportJobInvalidTransitionException.cs
new file mode 100644
index 000000000..13e4174c8
--- /dev/null
+++ b/src/ExportHistory/Exception/ExportJobInvalidTransitionException.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Exception thrown when an invalid state transition is attempted on an export job.
+///
+public class ExportJobInvalidTransitionException : InvalidOperationException
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ID of the export job on which the invalid transition was attempted.
+ /// The current status of the export job.
+ /// The target status that was invalid.
+ /// The name of the operation that was attempted.
+ public ExportJobInvalidTransitionException(string jobId, ExportJobStatus fromStatus, ExportJobStatus toStatus, string operationName)
+ : base($"Invalid state transition attempted for export job '{jobId}': Cannot transition from {fromStatus} to {toStatus} during {operationName} operation.")
+ {
+ this.JobId = jobId;
+ this.FromStatus = fromStatus;
+ this.ToStatus = toStatus;
+ this.OperationName = operationName;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ID of the export job on which the invalid transition was attempted.
+ /// The current status of the export job.
+ /// The target status that was invalid.
+ /// The name of the operation that was attempted.
+ /// The exception that is the cause of the current exception.
+ public ExportJobInvalidTransitionException(string jobId, ExportJobStatus fromStatus, ExportJobStatus toStatus, string operationName, Exception innerException)
+ : base($"Invalid state transition attempted for export job '{jobId}': Cannot transition from {fromStatus} to {toStatus} during {operationName} operation.", innerException)
+ {
+ this.JobId = jobId;
+ this.FromStatus = fromStatus;
+ this.ToStatus = toStatus;
+ this.OperationName = operationName;
+ }
+
+ ///
+ /// Gets the ID of the export job that encountered the invalid transition.
+ ///
+ public string JobId { get; }
+
+ ///
+ /// Gets the status the export job was transitioning from.
+ ///
+ public ExportJobStatus FromStatus { get; }
+
+ ///
+ /// Gets the invalid target status that was attempted.
+ ///
+ public ExportJobStatus ToStatus { get; }
+
+ ///
+ /// Gets the name of the operation that was attempted.
+ ///
+ public string OperationName { get; }
+}
+
diff --git a/src/ExportHistory/Exception/ExportJobNotFoundException.cs b/src/ExportHistory/Exception/ExportJobNotFoundException.cs
new file mode 100644
index 000000000..d1c2f9f19
--- /dev/null
+++ b/src/ExportHistory/Exception/ExportJobNotFoundException.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Exception thrown when attempting to access a schedule that does not exist.
+///
+public class ExportJobNotFoundException : InvalidOperationException
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ID of the export history job that was not found.
+ public ExportJobNotFoundException(string jobId)
+ : base($"Export history job with ID '{jobId}' was not found.")
+ {
+ this.JobId = jobId;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ID of the export history job that was not found.
+ /// The exception that is the cause of the current exception.
+ public ExportJobNotFoundException(string jobId, Exception innerException)
+ : base($"Export history job with ID '{jobId}' was not found.", innerException)
+ {
+ this.JobId = jobId;
+ }
+
+ ///
+ /// Gets the ID of the export history job that was not found.
+ ///
+ public string JobId { get; }
+}
diff --git a/src/ExportHistory/ExportHistory.csproj b/src/ExportHistory/ExportHistory.csproj
new file mode 100644
index 000000000..f80fe4877
--- /dev/null
+++ b/src/ExportHistory/ExportHistory.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net6.0
+ Durable Task Export History
+ true
+ preview.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ExportHistory/Extension/DurableTaskClientBuilderExtensions.cs b/src/ExportHistory/Extension/DurableTaskClientBuilderExtensions.cs
new file mode 100644
index 000000000..99b95624a
--- /dev/null
+++ b/src/ExportHistory/Extension/DurableTaskClientBuilderExtensions.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Extension methods for configuring Durable Task clients to use export history.
+///
+public static class DurableTaskClientBuilderExtensions
+{
+ ///
+ /// Enables export history support for the client builder with Azure Storage configuration.
+ ///
+ /// The client builder to add export history support to.
+ /// Callback to configure Azure Storage options. Must not be null.
+ /// The original builder, for call chaining.
+ public static IDurableTaskClientBuilder UseExportHistory(
+ this IDurableTaskClientBuilder builder,
+ Action configure)
+ {
+ Check.NotNull(builder, nameof(builder));
+ Check.NotNull(configure, nameof(configure));
+
+ IServiceCollection services = builder.Services;
+
+ // Register and validate options
+ services.AddOptions()
+ .Configure(configure)
+ .Validate(o =>
+ !string.IsNullOrEmpty(o.ConnectionString) &&
+ !string.IsNullOrEmpty(o.ContainerName),
+ $"{nameof(ExportHistoryStorageOptions)} must specify both {nameof(ExportHistoryStorageOptions.ConnectionString)} and {nameof(ExportHistoryStorageOptions.ContainerName)}.");
+
+ // Register ExportHistoryClient using validated options
+ services.AddSingleton(sp =>
+ {
+ DurableTaskClient durableTaskClient = sp.GetRequiredService();
+ ILogger logger = sp.GetRequiredService>();
+ ExportHistoryStorageOptions options = sp.GetRequiredService>().Value;
+
+ return new DefaultExportHistoryClient(durableTaskClient, logger, options);
+ });
+
+ return builder;
+ }
+}
diff --git a/src/ExportHistory/Extension/DurableTaskWorkerBuilderExtensions.cs b/src/ExportHistory/Extension/DurableTaskWorkerBuilderExtensions.cs
new file mode 100644
index 000000000..3e8a28967
--- /dev/null
+++ b/src/ExportHistory/Extension/DurableTaskWorkerBuilderExtensions.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+using Microsoft.DurableTask.Worker;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Extension methods for configuring Durable Task workers to use the Azure Durable Task Scheduler service.
+///
+public static class DurableTaskWorkerBuilderExtensions
+{
+ ///
+ /// Adds export history support to the worker builder.
+ ///
+ /// The worker builder to add export history support to.
+ public static void UseExportHistory(this IDurableTaskWorkerBuilder builder)
+ {
+ builder.AddTasks(r =>
+ {
+ r.AddEntity();
+ r.AddOrchestrator();
+ r.AddOrchestrator();
+ r.AddActivity();
+ r.AddActivity();
+ });
+ }
+}
diff --git a/src/ExportHistory/Logging/Logs.Client.cs b/src/ExportHistory/Logging/Logs.Client.cs
new file mode 100644
index 000000000..87f98bf1b
--- /dev/null
+++ b/src/ExportHistory/Logging/Logs.Client.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Log messages.
+///
+static partial class Logs
+{
+ [LoggerMessage(EventId = 80, Level = LogLevel.Information, Message = "Creating export job with options: {exportJobCreationOptions}")]
+ public static partial void ClientCreatingExportJob(this ILogger logger, ExportJobCreationOptions exportJobCreationOptions);
+
+
+ [LoggerMessage(EventId = 84, Level = LogLevel.Information, Message = "Deleting export job '{jobId}'")]
+ public static partial void ClientDeletingExportJob(this ILogger logger, string jobId);
+
+ [LoggerMessage(EventId = 87, Level = LogLevel.Error, Message = "{message} (JobId: {jobId})")]
+ public static partial void ClientError(this ILogger logger, string message, string jobId, Exception? exception = null);
+}
diff --git a/src/ExportHistory/Logging/Logs.Entity.cs b/src/ExportHistory/Logging/Logs.Entity.cs
new file mode 100644
index 000000000..1dd0d8257
--- /dev/null
+++ b/src/ExportHistory/Logging/Logs.Entity.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Log messages.
+///
+static partial class Logs
+{
+ [LoggerMessage(EventId = 101, Level = LogLevel.Information, Message = "Export job '{jobId}' is created")]
+ public static partial void CreatedExportJob(this ILogger logger, string jobId);
+
+ [LoggerMessage(EventId = 113, Level = LogLevel.Information, Message = "Export job '{jobId}' operation '{operationName}' info: {infoMessage}")]
+ public static partial void ExportJobOperationInfo(this ILogger logger, string jobId, string operationName, string infoMessage);
+
+ [LoggerMessage(EventId = 114, Level = LogLevel.Warning, Message = "Export job '{jobId}' operation '{operationName}' warning: {warningMessage}")]
+ public static partial void ExportJobOperationWarning(this ILogger logger, string jobId, string operationName, string warningMessage);
+
+ [LoggerMessage(EventId = 115, Level = LogLevel.Error, Message = "Operation '{operationName}' failed for export job '{jobId}': {errorMessage}")]
+ public static partial void ExportJobOperationError(this ILogger logger, string jobId, string operationName, string errorMessage, Exception? exception = null);
+}
diff --git a/src/ExportHistory/Models/CommitCheckpointRequest.cs b/src/ExportHistory/Models/CommitCheckpointRequest.cs
new file mode 100644
index 000000000..81745f5d6
--- /dev/null
+++ b/src/ExportHistory/Models/CommitCheckpointRequest.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Request to commit a checkpoint with progress updates and failures.
+///
+public sealed class CommitCheckpointRequest
+{
+ ///
+ /// Gets or sets the number of instances scanned in this batch.
+ ///
+ public long ScannedInstances { get; set; }
+
+ ///
+ /// Gets or sets the number of instances successfully exported in this batch.
+ ///
+ public long ExportedInstances { get; set; }
+
+ ///
+ /// Gets or sets the checkpoint to commit. If not null, the checkpoint is updated (cursor moves forward).
+ /// If null, the current checkpoint is kept (cursor does not move forward), allowing retry of the same batch.
+ ///
+ public ExportCheckpoint? Checkpoint { get; set; }
+
+ ///
+ /// Gets or sets the list of failed instance exports, if any.
+ ///
+ public List? Failures { get; set; }
+}
+
diff --git a/src/ExportHistory/Models/ExportCheckpoint.cs b/src/ExportHistory/Models/ExportCheckpoint.cs
new file mode 100644
index 000000000..2e404f2ca
--- /dev/null
+++ b/src/ExportHistory/Models/ExportCheckpoint.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Checkpoint information used to resume export.
+///
+public sealed class ExportCheckpoint
+{
+ ///
+ /// Gets or sets the last terminal time processed.
+ ///
+ public DateTimeOffset? LastTerminalTimeProcessed { get; set; }
+
+ ///
+ /// Gets or sets the last instance ID processed.
+ ///
+ public string? LastInstanceIdProcessed { get; set; }
+
+ ///
+ /// Gets or sets the continuation token for pagination.
+ ///
+ public string? ContinuationToken { get; set; }
+}
+
diff --git a/src/ExportHistory/Models/ExportDestination.cs b/src/ExportHistory/Models/ExportDestination.cs
new file mode 100644
index 000000000..fbab33b5b
--- /dev/null
+++ b/src/ExportHistory/Models/ExportDestination.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Export destination settings for Azure Blob Storage.
+///
+public sealed class ExportDestination
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The blob container name.
+ /// Thrown when container is null or empty.
+ public ExportDestination(string container)
+ {
+ Check.NotNullOrEmpty(container, nameof(container));
+ this.Container = container;
+ }
+
+ ///
+ /// Gets the blob container name.
+ ///
+ public string Container { get; }
+
+ ///
+ /// Gets or sets an optional prefix for blob paths.
+ ///
+ public string? Prefix { get; set; }
+}
+
diff --git a/src/ExportHistory/Models/ExportFailure.cs b/src/ExportHistory/Models/ExportFailure.cs
new file mode 100644
index 000000000..bd753a0dd
--- /dev/null
+++ b/src/ExportHistory/Models/ExportFailure.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Failure of a specific instance export.
+///
+/// The instance ID that failed to export.
+/// The reason for the failure.
+/// The number of attempts made.
+/// The timestamp of the last attempt.
+public sealed record ExportFailure(string InstanceId, string Reason, int AttemptCount, DateTimeOffset LastAttempt);
+
diff --git a/src/ExportHistory/Models/ExportFilter.cs b/src/ExportHistory/Models/ExportFilter.cs
new file mode 100644
index 000000000..c06dd540c
--- /dev/null
+++ b/src/ExportHistory/Models/ExportFilter.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Entities;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+public record ExportFilter(
+ DateTimeOffset CreatedTimeFrom,
+ DateTimeOffset? CreatedTimeTo = null,
+ IEnumerable? RuntimeStatus = null);
\ No newline at end of file
diff --git a/src/ExportHistory/Models/ExportFormat.cs b/src/ExportHistory/Models/ExportFormat.cs
new file mode 100644
index 000000000..778049ca1
--- /dev/null
+++ b/src/ExportHistory/Models/ExportFormat.cs
@@ -0,0 +1,8 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+public record ExportFormat(
+ string Kind = "jsonl",
+ string SchemaVersion = "1.0");
diff --git a/src/ExportHistory/Models/ExportJobConfiguration.cs b/src/ExportHistory/Models/ExportJobConfiguration.cs
new file mode 100644
index 000000000..42f7ac406
--- /dev/null
+++ b/src/ExportHistory/Models/ExportJobConfiguration.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+public record ExportJobConfiguration(
+ ExportMode Mode,
+ ExportFilter Filter,
+ ExportDestination Destination,
+ ExportFormat Format,
+ int MaxParallelExports = 32,
+ int MaxInstancesPerBatch = 100);
\ No newline at end of file
diff --git a/src/ExportHistory/Models/ExportJobCreationOptions.cs b/src/ExportHistory/Models/ExportJobCreationOptions.cs
new file mode 100644
index 000000000..dd465d857
--- /dev/null
+++ b/src/ExportHistory/Models/ExportJobCreationOptions.cs
@@ -0,0 +1,112 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Entities;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Configuration for a export job.
+///
+public record ExportJobCreationOptions
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The export mode (Batch or Continuous).
+ /// The start time for the export (inclusive). Required.
+ /// The end time for the export (inclusive). Required for Batch mode, null for Continuous mode.
+ /// The export destination where exported data will be stored. Required unless default storage is configured.
+ /// The unique identifier for the export job. If not provided, a GUID will be generated.
+ /// The export format settings. Optional, defaults to jsonl-gzip.
+ /// The orchestration runtime statuses to filter by. Optional.
+ /// The maximum number of instances to fetch per batch. Optional, defaults to 100.
+ /// Thrown when validation fails.
+ public ExportJobCreationOptions(
+ ExportMode mode,
+ DateTimeOffset createdTimeFrom,
+ DateTimeOffset? createdTimeTo,
+ ExportDestination? destination,
+ string? jobId = null,
+ ExportFormat? format = null,
+ List? runtimeStatus = null,
+ int? maxInstancesPerBatch = null)
+ {
+ // Generate GUID if jobId not provided
+ this.JobId = string.IsNullOrEmpty(jobId) ? Guid.NewGuid().ToString("N") : jobId;
+
+ if (mode == ExportMode.Batch && !createdTimeTo.HasValue)
+ {
+ throw new ArgumentException(
+ "CreatedTimeTo is required for Batch export mode. For Continuous mode, CreatedTimeTo must be null.",
+ nameof(createdTimeTo));
+ }
+
+ if (mode == ExportMode.Continuous && createdTimeTo.HasValue)
+ {
+ throw new ArgumentException(
+ "CreatedTimeTo must be null for Continuous export mode. For Batch mode, CreatedTimeTo is required.",
+ nameof(createdTimeTo));
+ }
+
+ // Validate terminal status-only filter here if provided
+ if (runtimeStatus?.Any() == true &&
+ runtimeStatus.Any(s => s is not (OrchestrationRuntimeStatus.Completed or OrchestrationRuntimeStatus.Failed or OrchestrationRuntimeStatus.Terminated or OrchestrationRuntimeStatus.ContinuedAsNew)))
+ {
+ throw new ArgumentException(
+ "Export supports terminal orchestration statuses only. Valid statuses are: Completed, Failed, Terminated, and ContinuedAsNew.",
+ nameof(runtimeStatus));
+ }
+
+ this.Mode = mode;
+ this.CreatedTimeFrom = createdTimeFrom;
+ this.CreatedTimeTo = createdTimeTo;
+ this.Destination = destination;
+ this.Format = format ?? new ExportFormat();
+ this.RuntimeStatus = runtimeStatus;
+ this.MaxInstancesPerBatch = maxInstancesPerBatch ?? 100;
+ }
+
+ ///
+ /// Gets the unique identifier for the export job.
+ ///
+ public string JobId { get; init; }
+
+ ///
+ /// Gets the export mode (Batch or Continuous).
+ ///
+ public ExportMode Mode { get; init; }
+
+ ///
+ /// Gets the start time for the export (inclusive). Required.
+ ///
+ public DateTimeOffset CreatedTimeFrom { get; init; }
+
+ ///
+ /// Gets the end time for the export (inclusive). Required for Batch mode, null for Continuous mode.
+ ///
+ public DateTimeOffset? CreatedTimeTo { get; init; }
+
+ ///
+ /// Gets the export destination where exported data will be stored. Optional.
+ ///
+ public ExportDestination? Destination { get; init; }
+
+ ///
+ /// Gets the export format settings.
+ ///
+ public ExportFormat Format { get; init; }
+
+ ///
+ /// Gets the orchestration runtime statuses to filter by.
+ /// If not specified, all terminal statuses are exported.
+ ///
+ public List? RuntimeStatus { get; init; }
+
+ ///
+ /// Gets the maximum number of instances to fetch per batch.
+ /// Defaults to 100.
+ ///
+ public int MaxInstancesPerBatch { get; init; }
+}
diff --git a/src/ExportHistory/Models/ExportJobDescription.cs b/src/ExportHistory/Models/ExportJobDescription.cs
new file mode 100644
index 000000000..bd1e2d4c1
--- /dev/null
+++ b/src/ExportHistory/Models/ExportJobDescription.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Represents the comprehensive details of an export job.
+///
+public record ExportJobDescription
+{
+ ///
+ /// Gets or sets the job identifier.
+ ///
+ public string JobId { get; init; } = string.Empty;
+
+ ///
+ /// Gets or sets the export job status.
+ ///
+ public ExportJobStatus Status { get; init; }
+
+ ///
+ /// Gets or sets the time when this export job was created.
+ ///
+ public DateTimeOffset? CreatedAt { get; init; }
+
+ ///
+ /// Gets or sets the time when this export job was last modified.
+ ///
+ public DateTimeOffset? LastModifiedAt { get; init; }
+
+ ///
+ /// Gets or sets the export job configuration.
+ ///
+ public ExportJobConfiguration? Config { get; init; }
+
+ ///
+ /// Gets or sets the instance ID of the running export orchestrator, if any.
+ ///
+ public string? OrchestratorInstanceId { get; init; }
+}
diff --git a/src/ExportHistory/Models/ExportJobQuery.cs b/src/ExportHistory/Models/ExportJobQuery.cs
new file mode 100644
index 000000000..8472ce3f1
--- /dev/null
+++ b/src/ExportHistory/Models/ExportJobQuery.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Represents query parameters for filtering export history jobs.
+///
+public record ExportJobQuery
+{
+ ///
+ /// The default page size when not supplied.
+ ///
+ public const int DefaultPageSize = 100;
+
+ ///
+ /// Gets the filter for the export history job status.
+ ///
+ public ExportJobStatus? Status { get; init; }
+
+ ///
+ /// Gets the prefix to filter export history job IDs.
+ ///
+ public string? JobIdPrefix { get; init; }
+
+ ///
+ /// Gets the filter for export history jobs created after this time.
+ ///
+ public DateTimeOffset? CreatedFrom { get; init; }
+
+ ///
+ /// Gets the filter for export history jobs created before this time.
+ ///
+ public DateTimeOffset? CreatedTo { get; init; }
+
+ ///
+ /// Gets the maximum number of export history jobs to return per page.
+ ///
+ public int? PageSize { get; init; }
+
+ ///
+ /// Gets the continuation token for retrieving the next page of results.
+ ///
+ public string? ContinuationToken { get; init; }
+}
diff --git a/src/ExportHistory/Models/ExportJobState.cs b/src/ExportHistory/Models/ExportJobState.cs
new file mode 100644
index 000000000..f1ea294a3
--- /dev/null
+++ b/src/ExportHistory/Models/ExportJobState.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Export job state stored in the entity.
+///
+public sealed class ExportJobState
+{
+ ///
+ /// Gets or sets the current status of the export job.
+ ///
+ public ExportJobStatus Status { get; set; }
+
+ ///
+ /// Gets or sets the export job configuration.
+ ///
+ public ExportJobConfiguration? Config { get; set; }
+
+ ///
+ /// Gets or sets the checkpoint for resuming the export.
+ ///
+ public ExportCheckpoint? Checkpoint { get; set; }
+
+ ///
+ /// Gets or sets the time when the export job was created.
+ ///
+ public DateTimeOffset? CreatedAt { get; set; }
+
+ ///
+ /// Gets or sets the time when the export job was last modified.
+ ///
+ public DateTimeOffset? LastModifiedAt { get; set; }
+
+ ///
+ /// Gets or sets the time of the last checkpoint.
+ ///
+ public DateTimeOffset? LastCheckpointTime { get; set; }
+
+ ///
+ /// Gets or sets the last error message, if any.
+ ///
+ public string? LastError { get; set; }
+
+ ///
+ /// Gets or sets the total number of instances scanned.
+ ///
+ public long ScannedInstances { get; set; }
+
+ ///
+ /// Gets or sets the total number of instances exported.
+ ///
+ public long ExportedInstances { get; set; }
+
+ ///
+ /// Gets or sets the dictionary of failed instance exports.
+ ///
+ public Dictionary FailedInstances { get; set; } = new();
+}
+
diff --git a/src/ExportHistory/Models/ExportJobStatus.cs b/src/ExportHistory/Models/ExportJobStatus.cs
new file mode 100644
index 000000000..2ff48a68f
--- /dev/null
+++ b/src/ExportHistory/Models/ExportJobStatus.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Represents the current status of an export history job.
+///
+public enum ExportJobStatus
+{
+ ///
+ /// Export history job has not been created.
+ ///
+ Uninitialized,
+
+ ///
+ /// Export history job is active and running.
+ ///
+ Active,
+
+ ///
+ /// Export history job failed.
+ ///
+ Failed,
+
+ ///
+ /// Export history job completed.
+ ///
+ Completed
+}
diff --git a/src/ExportHistory/Models/ExportJobTransitions.cs b/src/ExportHistory/Models/ExportJobTransitions.cs
new file mode 100644
index 000000000..8e1a24f36
--- /dev/null
+++ b/src/ExportHistory/Models/ExportJobTransitions.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Manages valid state transitions for export jobs.
+///
+static class ExportJobTransitions
+{
+ ///
+ /// Checks if a transition to the target state is valid for a given export job state and operation.
+ ///
+ /// The name of the operation being performed.
+ /// The current export job state.
+ /// The target state to transition to.
+ /// True if the transition is valid; otherwise, false.
+ public static bool IsValidTransition(string operationName, ExportJobStatus from, ExportJobStatus targetState)
+ {
+ return operationName switch
+ {
+ nameof(ExportJob.Create) => from switch
+ {
+ ExportJobStatus.Uninitialized when targetState == ExportJobStatus.Active => true,
+ ExportJobStatus.Failed when targetState == ExportJobStatus.Active => true,
+ ExportJobStatus.Completed when targetState == ExportJobStatus.Active => true,
+ _ => false,
+ },
+ nameof(ExportJob.MarkAsCompleted) => from switch
+ {
+ ExportJobStatus.Active when targetState == ExportJobStatus.Completed => true,
+ _ => false,
+ },
+ nameof(ExportJob.MarkAsFailed) => from switch
+ {
+ ExportJobStatus.Active when targetState == ExportJobStatus.Failed => true,
+ _ => false,
+ },
+ _ => false,
+ };
+ }
+}
+
diff --git a/src/ExportHistory/Models/ExportMode.cs b/src/ExportHistory/Models/ExportMode.cs
new file mode 100644
index 000000000..2e3ee0141
--- /dev/null
+++ b/src/ExportHistory/Models/ExportMode.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Entities;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Export job modes.
+///
+public enum ExportMode
+{
+ /// Exports a fixed window and completes.
+ Batch = 1,
+ /// Tails terminal instances continuously.
+ Continuous = 2,
+}
\ No newline at end of file
diff --git a/src/ExportHistory/Options/ExportHistoryStorageOptions.cs b/src/ExportHistory/Options/ExportHistoryStorageOptions.cs
new file mode 100644
index 000000000..21d838187
--- /dev/null
+++ b/src/ExportHistory/Options/ExportHistoryStorageOptions.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Options for Azure Storage configuration for export history jobs.
+/// Supports connection string-based authentication.
+///
+public sealed class ExportHistoryStorageOptions
+{
+ ///
+ /// Gets or sets the Azure Storage connection string to the customer's storage account.
+ ///
+ public string ConnectionString { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the blob container name where export data will be stored.
+ ///
+ public string ContainerName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets an optional prefix for blob paths.
+ ///
+ public string? Prefix { get; set; }
+}
+
diff --git a/src/ExportHistory/Orchestrations/ExecuteExportJobOperationOrchestrator.cs b/src/ExportHistory/Orchestrations/ExecuteExportJobOperationOrchestrator.cs
new file mode 100644
index 000000000..a860aa516
--- /dev/null
+++ b/src/ExportHistory/Orchestrations/ExecuteExportJobOperationOrchestrator.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.DurableTask.Entities;
+
+namespace Microsoft.DurableTask.ExportHistory;
+
+///
+/// Orchestrator that executes operations on export job entities.
+/// Calls the specified operation on the target entity and returns the result.
+///
+[DurableTask]
+public class ExecuteExportJobOperationOrchestrator : TaskOrchestrator
+{
+ ///
+ public override async Task