diff --git a/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/ExportHistoryWebApp.csproj b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/ExportHistoryWebApp.csproj new file mode 100644 index 0000000..93d619e --- /dev/null +++ b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/ExportHistoryWebApp.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + true + $(BaseIntermediateOutputPath)Generated + + + + + + + + + + + + + + + + diff --git a/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/ExportHistoryWebApp.http b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/ExportHistoryWebApp.http new file mode 100644 index 0000000..03b40b2 --- /dev/null +++ b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/ExportHistoryWebApp.http @@ -0,0 +1,88 @@ +### Variables +@baseUrl = http://localhost:5009 +@jobId = export-job-12345 + +### Create a new batch export job +# @name createBatchExportJob +POST {{baseUrl}}/export-jobs +Content-Type: application/json + +{ + "jobId": "{{jobId}}", + "mode": "Batch", + "completedTimeFrom": "2025-10-01T00:00:00Z", + "completedTimeTo": "2025-11-06T23:59:59Z", + "container": "export-history", + "prefix": "batch-exports/", + "maxInstancesPerBatch": 1, + "runtimeStatus": [] +} + +### Create a new continuous export job +# @name createContinuousExportJob +POST {{baseUrl}}/export-jobs +Content-Type: application/json + +{ + "jobId": "export-job-continuous-123", + "mode": "Continuous", + "completedTimeFrom": "2025-10-01T00:00:00Z", + "container": "export-history", + "prefix": "continuous-exports/", + "maxInstancesPerBatch": 1000 +} + +### 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", + "completedTimeFrom": "2024-01-01T00:00:00Z", + "completedTimeTo": "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}} + +# Delete a continuous export job +DELETE {{baseUrl}}/export-jobs/export-job-continuous-123 + +### 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 completedTimeTo) +# - "Continuous": Continuously exports instances from a start time (completedTimeTo must be null) +# - Runtime status filters (valid values): +# - "Completed": Exports only completed orchestrations +# - "Failed": Exports only failed orchestrations +# - "Terminated": Exports only terminated 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/durable-task-sdks/dotnet/ExportHistoryWebApp/ExportJobController.cs b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/ExportJobController.cs new file mode 100644 index 0000000..8599504 --- /dev/null +++ b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/ExportJobController.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.DurableTask; +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.Container)) + { + destination = new ExportDestination(request.Container) + { + Prefix = request.Prefix, + }; + } + + ExportJobCreationOptions creationOptions = new ExportJobCreationOptions( + mode: request.Mode, + completedTimeFrom: request.CompletedTimeFrom, + completedTimeTo: request.CompletedTimeTo, + 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) + { + this.logger.LogInformation("GET list endpoint called with method: {Method}", this.HttpContext.Request.Method); + 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) + { + this.logger.LogInformation("DELETE endpoint called for job ID: {JobId}", id); + try + { + ExportHistoryJobClient jobClient = this.exportHistoryClient.GetJobClient(id); + await jobClient.DeleteAsync(); + this.logger.LogInformation("Successfully deleted export job {JobId}", id); + return this.NoContent(); + } + catch (ExportJobNotFoundException) + { + this.logger.LogWarning("Export job {JobId} not found for deletion", id); + 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/durable-task-sdks/dotnet/ExportHistoryWebApp/Models/CreateExportJobRequest.cs b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/Models/CreateExportJobRequest.cs new file mode 100644 index 0000000..1e09ddf --- /dev/null +++ b/samples/durable-task-sdks/dotnet/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 based on completion time (inclusive). Required. + /// + public DateTimeOffset CompletedTimeFrom { get; set; } + + /// + /// Gets or sets the end time for the export based on completion time (inclusive). Required for Batch mode, null for Continuous mode. + /// + public DateTimeOffset? CompletedTimeTo { get; set; } + + /// + /// Gets or sets the blob container name where exported data will be stored. Optional if default storage is configured. + /// + public string? Container { 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. + /// + 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/durable-task-sdks/dotnet/ExportHistoryWebApp/Program.cs b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/Program.cs new file mode 100644 index 0000000..46ee02f --- /dev/null +++ b/samples/durable-task-sdks/dotnet/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/durable-task-sdks/dotnet/ExportHistoryWebApp/Properties/launchSettings.json b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/Properties/launchSettings.json new file mode 100644 index 0000000..4375d42 --- /dev/null +++ b/samples/durable-task-sdks/dotnet/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/durable-task-sdks/dotnet/ExportHistoryWebApp/README.md b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/README.md new file mode 100644 index 0000000..72c847d --- /dev/null +++ b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/README.md @@ -0,0 +1,115 @@ +# Export History Web App Sample + +This sample is a small ASP.NET Core web app that exposes a REST API for creating and managing Durable Task **export history jobs**. + +It uses: + +- **Durable Task Scheduler (Azure Managed)** for listing instance history +- **Azure Blob Storage** as the export destination for exported orchestration history + +## Prerequisites + +- A Durable Task Scheduler hub connection string +- An Azure Storage account connection string (or Azurite/Storage Emulator) + +## Configure + +The app reads configuration from standard .NET configuration sources (environment variables, `appsettings*.json`, command-line, etc.). + +Required settings: + +- `DURABLE_TASK_CONNECTION_STRING` + - Durable Task Scheduler connection string for your task hub. +- `EXPORT_HISTORY_STORAGE_CONNECTION_STRING` + - Azure Storage connection string used for writing exported history. +- `EXPORT_HISTORY_CONTAINER_NAME` + - Default blob container name for export output. + +Optional settings: + +- `EXPORT_HISTORY_PREFIX` + - Default blob “folder” prefix used when writing blobs. + +## Run + +From the repo root: + +```bash +dotnet run --project samples/durable-task-sdks/dotnet/ExportHistoryWebApp/ExportHistoryWebApp.csproj +``` + +The default `launchSettings.json` profile listens on: + +- `http://localhost:5009` + +## Interact with the export API + +The controller is rooted at `export-jobs` and supports create/get/list/delete. + +### Create an export job + +`POST /export-jobs` + +Request body (see `Models/CreateExportJobRequest.cs`): + +- `jobId` (optional): If omitted, a GUID is generated. +- `mode`: `Batch` or `Continuous`. +- `completedTimeFrom`: Start of the export time window (inclusive). +- `completedTimeTo`: + - Required for `Batch` + - Must be omitted/null for `Continuous` +- `container` / `prefix` (optional): Overrides the default destination configured in app settings. +- `runtimeStatus` (optional): Filters exported instances by terminal status. + - Allowed values: `Completed`, `Failed`, `Terminated` +- `maxInstancesPerBatch` (optional): 1–1000 (defaults to 100). +- `format` (optional): Defaults to JSONL + gzip. + +Notes: +- For `Batch` mode, `completedTimeTo` must be greater than `completedTimeFrom` and cannot be in the future. + +### Get a job + +`GET /export-jobs/{id}` + +Returns an `ExportJobDescription` if the job exists. + +### List jobs + +`GET /export-jobs/list` + +Optional query parameters: + +- `status`: `Pending`, `Active`, `Failed`, `Completed` +- `jobIdPrefix` +- `createdFrom`, `createdTo` +- `pageSize`, `continuationToken` + +### Delete a job + +`DELETE /export-jobs/{id}` + +## Where exported data goes + +Exported history is written to Azure Blob Storage: + +- Container: default from `EXPORT_HISTORY_CONTAINER_NAME` (or per-request `container` override) +- Blob name: derived from a SHA-256 hash of `(completedTimestamp, instanceId)` +- File extension: + - Default: `.jsonl.gz` (JSON Lines, gzip-compressed) + - Optional: `.json` (if configured via `format`) + +If a prefix is configured, the blob path becomes: + +- `{prefix}/{hash}.{ext}` + +## Using the included HTTP file + +This sample includes ready-made requests in `ExportHistoryWebApp.http`. + +In VS Code: + +1. Install the “REST Client” extension (if you don’t already have it). +2. Open `ExportHistoryWebApp.http`. +3. Click “Send Request” on any request block. + +Adjust the `@baseUrl` variable if you run the app on a different port. diff --git a/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/appsettings.Development.json b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/appsettings.Development.json new file mode 100644 index 0000000..21b22db --- /dev/null +++ b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} + diff --git a/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/appsettings.json b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/appsettings.json new file mode 100644 index 0000000..fb87850 --- /dev/null +++ b/samples/durable-task-sdks/dotnet/ExportHistoryWebApp/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} +