diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..89b575ef3
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,22 @@
+version: 2
+updates:
+ - package-ecosystem: "nuget"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "tuesday"
+ ignore:
+ - dependency-name: "*"
+ update-types:
+ - "version-update:semver-major"
+
+ - package-ecosystem: "dotnet-sdk"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "tuesday"
+ ignore:
+ - dependency-name: "*"
+ update-types:
+ - "version-update:semver-major"
+
\ No newline at end of file
diff --git a/.github/workflows/codeQL.yml b/.github/workflows/codeQL.yml
index 58eac188b..dae391413 100644
--- a/.github/workflows/codeQL.yml
+++ b/.github/workflows/codeQL.yml
@@ -66,6 +66,11 @@ jobs:
with:
dotnet-version: '3.1.x'
+ - name: Set up .NET 8
+ uses: actions/setup-dotnet@v3
+ with:
+ dotnet-version: '8.0.x'
+
- name: Restore dependencies
run: dotnet restore $solution
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e9c7569ca..844d46238 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,15 +11,15 @@
-
-
+
+
-
-
+
+
-
+
-
+
@@ -35,8 +35,8 @@
-
-
+
+
@@ -47,9 +47,9 @@
-
-
-
+
+
+
@@ -60,13 +60,14 @@
-
-
-
+
+
+
+
-
-
+
+
@@ -78,8 +79,8 @@
-
-
+
+
@@ -88,7 +89,7 @@
-
+
@@ -103,9 +104,9 @@
-
-
-
+
+
+
@@ -114,8 +115,8 @@
-
-
+
+
diff --git a/README.md b/README.md
index 04177195b..c2184bda9 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,18 @@
# Durable Task Framework
-The Durable Task Framework (DTFx) is a library that allows users to write long running persistent workflows (referred to as _orchestrations_) in C# using simple async/await coding constructs. It is used heavily within various teams at Microsoft to reliably orchestrate long running provisioning, monitoring, and management operations. The orchestrations scale out linearly by simply adding more worker machines. This framework is also used to power the serverless [Durable Functions](https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-overview) extension of [Azure Functions](https://azure.microsoft.com/services/functions/).
+> [!IMPORTANT]
+> The Durable Task Framework (DTFx) is a community-maintained open-source project. It is actively used in production by many teams, including engineering teams within Microsoft. However, it does not come with official Microsoft support — meaning you cannot open a Microsoft support ticket for DTFx issues. Bugs and feature requests are addressed on a best-effort basis.
+>
+> If you are starting a new project or need official Microsoft support, we recommend:
+>
+> - **[Durable Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview)** - for serverless orchestration on [Azure Functions](https://azure.microsoft.com/services/functions/)
+> - **[Durable Task SDKs](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-overview)** with the **[Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler)** backend - for self-hosted orchestration on any compute platform (Azure Container Apps, AKS, VMs, etc.)
+>
+> These alternatives offer full Microsoft support, including the ability to open support tickets. For more details, see [Choosing an orchestration framework](https://learn.microsoft.com/azure/azure-functions/durable/choose-orchestration-framework#community-and-experimental-durable-task-sdks).
-By open sourcing this project we hope to give the community a very cost-effective alternative to heavy duty workflow systems. We also hope to build an ecosystem of providers and activities around this simple yet incredibly powerful framework.
+The Durable Task Framework (DTFx) is a library that allows users to write long-running persistent workflows (referred to as _orchestrations_) in C# using simple async/await coding constructs. It provides similar orchestration primitives to the modern Durable Task SDKs. While DTFx continues to be maintained and used in production, the newer Durable Task SDKs offer additional features, active development, and official Microsoft support. DTFx also requires you to manage hosting and operational infrastructure yourself.
+
+> **📖 Documentation:** Documentation for this repository is available in the [docs](./docs/README.md) folder.
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
@@ -21,13 +31,15 @@ Starting in v2.x, the Durable Task Framework supports an extensible set of backe
| DurableTask.SqlServer | [](https://www.nuget.org/packages/Microsoft.DurableTask.SqlServer/) | All orchestration state is stored in a [Microsoft SQL Server](https://www.microsoft.com/sql-server/sql-server-2019) or [Azure SQL](https://azure.microsoft.com/products/azure-sql/database/) database with indexed tables and stored procedures for direct interaction. This backend is available for [Durable Functions](https://docs.microsoft.com/azure/azure-functions/durable/). [👉 GitHub Repo](https://github.com/microsoft/durabletask-mssql) | Production ready and actively maintained |
| DurableTask.Emulator | [](https://www.nuget.org/packages/Microsoft.Azure.DurableTask.Emulator/) | This is an in-memory store intended for testing purposes only. It is not designed or recommended for any production workloads. | Not actively maintained |
-The core programming model for the Durable Task Framework is contained in the [DurableTask.Core](https://www.nuget.org/packages/Microsoft.Azure.DurableTask.Core/) package, which is also under active development.
+> [!NOTE]
+> The `DurableTask.Emulator` listed above is a legacy in-memory backend for DTFx and is **not** the same as the [Durable Task Scheduler emulator](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/develop-with-durable-task-scheduler?tabs=dedicated&pivots=az-cli#durable-task-scheduler-emulator), which is a supported local development emulator for the Durable Task Scheduler backend.
+
+The core programming model for the Durable Task Framework is contained in the [DurableTask.Core](https://www.nuget.org/packages/Microsoft.Azure.DurableTask.Core/) package.
## Learning more
There are several places where you can learn more about this framework. Note that some are external and not owned by Microsoft:
-- [This repo's wiki](https://github.com/Azure/durabletask/wiki), which contains more details about the framework and how it can be used.
- The following blog series contains useful information: https://abhikmitra.github.io/blog/durable-task/
- Several useful samples are available here: https://github.com/kaushiksk/durabletask-samples
- You can watch a video with some of the original maintainers in [Building Workflows with the Durable Task Framework](https://learn.microsoft.com/shows/on-net/building-workflows-with-the-durable-task-framework).
diff --git a/Test/DurableTask.AzureStorage.Tests/OrchestrationSessionTests.cs b/Test/DurableTask.AzureStorage.Tests/OrchestrationSessionTests.cs
new file mode 100644
index 000000000..126c4b9bc
--- /dev/null
+++ b/Test/DurableTask.AzureStorage.Tests/OrchestrationSessionTests.cs
@@ -0,0 +1,227 @@
+// ----------------------------------------------------------------------------------
+// Copyright Microsoft Corporation
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ----------------------------------------------------------------------------------
+
+namespace DurableTask.AzureStorage.Tests
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Diagnostics;
+ using System.Linq;
+ using System.Reflection;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using DurableTask.AzureStorage.Messaging;
+ using DurableTask.AzureStorage.Monitoring;
+ using DurableTask.AzureStorage.Tracking;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+ using Moq;
+
+ ///
+ /// Tests for shutdown cancellation behavior with extended sessions.
+ ///
+ [TestClass]
+ public class OrchestrationSessionTests
+ {
+ ///
+ /// Verifies that
+ /// exits immediately when the cancellation token is cancelled.
+ ///
+ [TestMethod]
+ public async Task WaitAsync_CancellationToken_ExitsImmediately()
+ {
+ var resetEvent = new AsyncAutoResetEvent(signaled: false);
+ using var cts = new CancellationTokenSource();
+
+ TimeSpan longTimeout = TimeSpan.FromSeconds(30);
+ Task waitTask = resetEvent.WaitAsync(longTimeout, cts.Token);
+
+ Assert.IsFalse(waitTask.IsCompleted, "Wait should not complete immediately");
+
+ var stopwatch = Stopwatch.StartNew();
+ cts.Cancel();
+
+ bool result = await waitTask;
+ stopwatch.Stop();
+
+ Assert.IsFalse(result, "Cancellation should return false (no signal received)");
+ Assert.IsTrue(
+ stopwatch.ElapsedMilliseconds < 5000,
+ $"Cancellation should complete in under 5s, but took {stopwatch.ElapsedMilliseconds}ms");
+ }
+
+ ///
+ /// Verifies that signaling still returns true when a cancellation token is provided.
+ ///
+ [TestMethod]
+ public async Task WaitAsync_WithCancellationToken_SignalStillWorks()
+ {
+ var resetEvent = new AsyncAutoResetEvent(signaled: false);
+ using var cts = new CancellationTokenSource();
+
+ Task waitTask = resetEvent.WaitAsync(TimeSpan.FromSeconds(30), cts.Token);
+ Assert.IsFalse(waitTask.IsCompleted);
+
+ resetEvent.Set();
+
+ Task winner = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(5)));
+ Assert.IsTrue(winner == waitTask, "Signal should wake the waiter");
+ Assert.IsTrue(waitTask.Result, "Wait result should be true when signaled");
+ }
+
+ ///
+ /// Verifies that the wait returns false on timeout when a cancellation token is provided but not cancelled.
+ ///
+ [TestMethod]
+ public async Task WaitAsync_WithCancellationToken_TimeoutStillWorks()
+ {
+ var resetEvent = new AsyncAutoResetEvent(signaled: false);
+ using var cts = new CancellationTokenSource();
+
+ bool result = await resetEvent.WaitAsync(TimeSpan.FromMilliseconds(100), cts.Token);
+
+ Assert.IsFalse(result, "Wait should return false on timeout");
+ }
+
+ ///
+ /// Verifies that all queued waiters return false when the token is cancelled.
+ ///
+ [TestMethod]
+ public async Task WaitAsync_CancellationToken_MultipleWaiters()
+ {
+ var resetEvent = new AsyncAutoResetEvent(signaled: false);
+ using var cts = new CancellationTokenSource();
+
+ var waiters = new List>();
+ for (int i = 0; i < 5; i++)
+ {
+ waiters.Add(resetEvent.WaitAsync(TimeSpan.FromSeconds(30), cts.Token));
+ }
+
+ foreach (var waiter in waiters)
+ {
+ Assert.IsFalse(waiter.IsCompleted);
+ }
+
+ var stopwatch = Stopwatch.StartNew();
+ cts.Cancel();
+
+ // All waiters should return false (cancelled = not signaled)
+ await Task.WhenAll(
+ waiters.Select(
+ async waiter =>
+ {
+ bool result = await waiter;
+ Assert.IsFalse(result, "Cancelled waiter should return false");
+ }));
+
+ stopwatch.Stop();
+
+ Assert.IsTrue(
+ stopwatch.ElapsedMilliseconds < 5000,
+ $"All waiters should complete in under 5s, but took {stopwatch.ElapsedMilliseconds}ms");
+ }
+
+ ///
+ /// Verifies that a pre-cancelled token causes WaitAsync to return false immediately.
+ ///
+ [TestMethod]
+ public async Task WaitAsync_AlreadyCancelledToken_ReturnsFalseImmediately()
+ {
+ var resetEvent = new AsyncAutoResetEvent(signaled: false);
+ using var cts = new CancellationTokenSource();
+ cts.Cancel(); // Pre-cancel
+
+ var stopwatch = Stopwatch.StartNew();
+ bool result = await resetEvent.WaitAsync(TimeSpan.FromSeconds(30), cts.Token);
+ stopwatch.Stop();
+
+ Assert.IsFalse(result, "Pre-cancelled token should cause immediate false return");
+ Assert.IsTrue(
+ stopwatch.ElapsedMilliseconds < 5000,
+ $"Should complete immediately, but took {stopwatch.ElapsedMilliseconds}ms");
+ }
+
+ ///
+ /// Verifies that a pre-cancelled token still returns true if the event is already signaled.
+ ///
+ [TestMethod]
+ public async Task WaitAsync_AlreadySignaledAndCancelled_ReturnsTrue()
+ {
+ var resetEvent = new AsyncAutoResetEvent(signaled: true);
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ bool result = await resetEvent.WaitAsync(TimeSpan.FromSeconds(30), cts.Token);
+ Assert.IsTrue(result, "Already signaled event should return true even with cancelled token");
+ }
+
+ ///
+ /// Verifies that clears all active sessions.
+ ///
+ [TestMethod]
+ public void AbortAllSessions_ClearsActiveSessions()
+ {
+ var settings = new AzureStorageOrchestrationServiceSettings();
+ var stats = new AzureStorageOrchestrationServiceStats();
+ var trackingStore = new Mock();
+
+ using var manager = new OrchestrationSessionManager(
+ "testaccount",
+ settings,
+ stats,
+ trackingStore.Object);
+
+ // Use reflection to access the internal sessions dictionary.
+ var sessionsField = typeof(OrchestrationSessionManager)
+ .GetField("activeOrchestrationSessions", BindingFlags.NonPublic | BindingFlags.Instance);
+ var sessions = (Dictionary)sessionsField.GetValue(manager);
+
+ manager.GetStats(out _, out _, out int initialCount);
+ Assert.AreEqual(0, initialCount, "Should start with no active sessions");
+
+ sessions["instance1"] = null;
+ sessions["instance2"] = null;
+ sessions["instance3"] = null;
+
+ manager.GetStats(out _, out _, out int activeCount);
+ Assert.AreEqual(3, activeCount, "Should have 3 active sessions");
+
+ manager.AbortAllSessions();
+
+ manager.GetStats(out _, out _, out int afterAbortCount);
+ Assert.AreEqual(0, afterAbortCount, "AbortAllSessions should clear all active sessions");
+ }
+
+ ///
+ /// Verifies that is safe to call with no active sessions.
+ ///
+ [TestMethod]
+ public void AbortAllSessions_NoSessions_DoesNotThrow()
+ {
+ var settings = new AzureStorageOrchestrationServiceSettings();
+ var stats = new AzureStorageOrchestrationServiceStats();
+ var trackingStore = new Mock();
+
+ using var manager = new OrchestrationSessionManager(
+ "testaccount",
+ settings,
+ stats,
+ trackingStore.Object);
+
+ manager.AbortAllSessions();
+
+ manager.GetStats(out _, out _, out int count);
+ Assert.AreEqual(0, count, "Should still have no active sessions");
+ }
+ }
+}
diff --git a/Test/DurableTask.Core.Tests/TraceHelperTests.cs b/Test/DurableTask.Core.Tests/TraceHelperTests.cs
new file mode 100644
index 000000000..8d52754fa
--- /dev/null
+++ b/Test/DurableTask.Core.Tests/TraceHelperTests.cs
@@ -0,0 +1,69 @@
+// ----------------------------------------------------------------------------------
+// Copyright Microsoft Corporation
+// Licensed under the Apache License, Version 2.0 (the "License");
+// You may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ----------------------------------------------------------------------------------
+#if !NET462
+#nullable enable
+namespace DurableTask.Core.Tests
+{
+ using System.Collections.Generic;
+ using System.Diagnostics;
+ using DurableTask.Core.Entities.OperationFormat;
+ using DurableTask.Core.Tracing;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+ using DiagnosticsActivityStatusCode = System.Diagnostics.ActivityStatusCode;
+ using TraceActivityStatusCode = DurableTask.Core.Tracing.ActivityStatusCode;
+
+ [TestClass]
+ public class TraceHelperTests
+ {
+ [TestMethod]
+ public void EndActivitiesForEntityInvocationResetsSuccessfulStatus()
+ {
+ var activities = new List
+ {
+ new Activity("entityOperation").Start()
+ };
+ activities[0].SetStatus(TraceActivityStatusCode.Error, "instrumented error");
+
+ var results = new List
+ {
+ new OperationResult()
+ };
+
+ TraceHelper.EndActivitiesForProcessingEntityInvocation(activities, results, batchFailureDetails: null);
+
+ Assert.AreEqual(DiagnosticsActivityStatusCode.Ok, activities[0].Status);
+ }
+
+ [TestMethod]
+ public void EndActivitiesForEntityInvocationMarksFailures()
+ {
+ var activities = new List
+ {
+ new Activity("entityOperation").Start()
+ };
+
+ var failingResults = new List
+ {
+ new OperationResult
+ {
+ ErrorMessage = "entity failure"
+ }
+ };
+
+ TraceHelper.EndActivitiesForProcessingEntityInvocation(activities, failingResults, batchFailureDetails: null);
+
+ Assert.AreEqual(DiagnosticsActivityStatusCode.Error, activities[0].Status);
+ }
+ }
+}
+#endif
diff --git a/Test/DurableTask.ServiceBus.Tests/SessionIdCaseInsensitiveTests.cs b/Test/DurableTask.ServiceBus.Tests/SessionIdCaseInsensitiveTests.cs
new file mode 100644
index 000000000..0a89e92dc
--- /dev/null
+++ b/Test/DurableTask.ServiceBus.Tests/SessionIdCaseInsensitiveTests.cs
@@ -0,0 +1,167 @@
+// ----------------------------------------------------------------------------------
+// Copyright Microsoft Corporation
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ----------------------------------------------------------------------------------
+
+namespace DurableTask.ServiceBus.Tests
+{
+ using System;
+ using System.Collections.Concurrent;
+ using System.Reflection;
+ using DurableTask.ServiceBus.Settings;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ ///
+ /// Tests that validate case-insensitive session ID handling in ServiceBusOrchestrationService.
+ ///
+ /// Background: Service Bus can change the casing of session IDs during upgrades or failovers.
+ /// The DurableTask framework must handle session IDs case-insensitively to prevent ghost sessions,
+ /// orphaned orchestration state, and stuck eternal orchestrations.
+ ///
+ [TestClass]
+ public class SessionIdCaseInsensitiveTests
+ {
+ ///
+ /// Validates that the orchestrationSessions dictionary uses case-insensitive key comparison.
+ /// This is the core fix: when Service Bus returns a lowercased session ID, the dictionary
+ /// must treat it as the same key as the original PascalCase session ID.
+ ///
+ [TestMethod]
+ public void OrchestrationSessionsDictionary_ShouldBeCaseInsensitive()
+ {
+ // Simulate the dictionary as initialized in ServiceBusOrchestrationService.StartAsync()
+ var sessions = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+
+ string pascalCaseId = "System_BillingConsumption_8a376298-1463-4440-905f-a836774c1460";
+ string lowerCaseId = "system_billingconsumption_8a376298-1463-4440-905f-a836774c1460";
+
+ var sessionState = new ServiceBusOrchestrationSession();
+
+ // Add with PascalCase (as originally created by APIM)
+ Assert.IsTrue(sessions.TryAdd(pascalCaseId, sessionState));
+
+ // Attempt to add with lowercase (as returned by Service Bus after upgrade)
+ // should FAIL because case-insensitive comparison treats them as the same key
+ Assert.IsFalse(sessions.TryAdd(lowerCaseId, sessionState),
+ "Lowercase session ID should be treated as duplicate of PascalCase session ID");
+
+ // Lookup by lowercase should find the PascalCase entry
+ Assert.IsTrue(sessions.TryGetValue(lowerCaseId, out var retrieved),
+ "Should be able to look up session by lowercase ID");
+ Assert.AreSame(sessionState, retrieved);
+
+ // Removal by lowercase should remove the PascalCase entry
+ Assert.IsTrue(sessions.TryRemove(lowerCaseId, out var removed),
+ "Should be able to remove session by lowercase ID");
+ Assert.AreSame(sessionState, removed);
+ Assert.AreEqual(0, sessions.Count, "Dictionary should be empty after removal");
+ }
+
+ ///
+ /// Validates that the orchestrationMessages dictionary uses case-insensitive key comparison.
+ ///
+ [TestMethod]
+ public void OrchestrationMessagesDictionary_ShouldBeCaseInsensitive()
+ {
+ var messages = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+
+ string messageId = "2B9C5D18F1C2416390221C250F38DF94";
+ string lowerMessageId = "2b9c5d18f1c2416390221c250f38df94";
+
+ var message = new DurableTask.ServiceBus.Common.Abstraction.Message(new byte[0]);
+
+ Assert.IsTrue(messages.TryAdd(messageId, message));
+ Assert.IsFalse(messages.TryAdd(lowerMessageId, message),
+ "Lowercase message ID should be treated as duplicate");
+ }
+
+ ///
+ /// 1. Timer message sent with PascalCase session ID
+ /// 2. Timer message received with lowercase session ID
+ /// 3. With case-insensitive dictionary, the lookup should succeed
+ ///
+ [TestMethod]
+ public void SessionLookup_WithMixedCaseSessionIds_ShouldSucceed()
+ {
+ var sessions = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Simulate the real scenario from api-kw1-prod-01
+ string originalSessionId = "System_MoveBillingEvents_a3c79b00";
+ string lowercasedSessionId = "system_movebillingevents_a3c79b00";
+
+ var sessionState = new ServiceBusOrchestrationSession();
+
+ // Step 1: Session added during LockNextTaskOrchestrationWorkItemAsync with original casing
+ sessions.TryAdd(originalSessionId, sessionState);
+
+ // Step 2: After ContinueAsNew, timer fires and Service Bus returns lowercase session ID
+ // The framework looks up the session by the (now lowercased) workItem.InstanceId
+ bool found = sessions.TryGetValue(lowercasedSessionId, out var retrievedSession);
+
+ Assert.IsTrue(found,
+ "Session lookup with lowercased ID should find the original PascalCase session. " +
+ "Without this fix, a ghost session would be created and the orchestration would be stuck forever.");
+ Assert.AreSame(sessionState, retrievedSession);
+ }
+
+ ///
+ /// Validates that the case-insensitive dictionary prevents the ghost session scenario.
+ /// In the original bug, a lowercased session ID would create a NEW entry in the dictionary,
+ /// leading to a ghost session with empty state that would immediately die.
+ ///
+ [TestMethod]
+ public void GhostSessionPrevention_DuplicateAddWithDifferentCasing_ShouldFail()
+ {
+ var sessions = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+
+ string[] casingVariants = new[]
+ {
+ "System_BillingConsumption_8a376298-1463-4440-905f-a836774c1460",
+ "system_billingconsumption_8a376298-1463-4440-905f-a836774c1460",
+ "SYSTEM_BILLINGCONSUMPTION_8A376298-1463-4440-905F-A836774C1460",
+ "System_billingConsumption_8A376298-1463-4440-905f-A836774c1460",
+ };
+
+ // First add should succeed
+ Assert.IsTrue(sessions.TryAdd(casingVariants[0], new ServiceBusOrchestrationSession()));
+
+ // All other casing variants should be treated as duplicates
+ for (int i = 1; i < casingVariants.Length; i++)
+ {
+ Assert.IsFalse(sessions.TryAdd(casingVariants[i], new ServiceBusOrchestrationSession()),
+ $"Casing variant '{casingVariants[i]}' should be treated as duplicate of '{casingVariants[0]}'");
+ }
+
+ Assert.AreEqual(1, sessions.Count, "Dictionary should contain exactly one entry regardless of casing variants");
+ }
+
+ ///
+ /// Verifies that the ServiceBusOrchestrationService.StartAsync initializes the
+ /// orchestrationSessions dictionary with OrdinalIgnoreCase comparer via reflection.
+ ///
+ [TestMethod]
+ public void StartAsync_OrchestrationSessionsDictionary_UsesCaseInsensitiveComparer()
+ {
+ // Use reflection to verify the field type has the correct comparer after initialization.
+ // We check the declaration to ensure the fix is present in the code.
+ var fieldInfo = typeof(ServiceBusOrchestrationService).GetField(
+ "orchestrationSessions",
+ BindingFlags.NonPublic | BindingFlags.Instance);
+
+ Assert.IsNotNull(fieldInfo,
+ "Expected private field 'orchestrationSessions' on ServiceBusOrchestrationService");
+ Assert.AreEqual(
+ typeof(ConcurrentDictionary),
+ fieldInfo.FieldType,
+ "orchestrationSessions should be ConcurrentDictionary");
+ }
+ }
+}
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 000000000..a50ec54fc
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,31 @@
+# Durable Task Framework Documentation
+
+The Durable Task Framework (DTFx) is an open-source framework for writing long-running, fault-tolerant workflow orchestrations in .NET. It provides the foundation for [Azure Durable Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) and can be used standalone with various backend storage providers.
+
+## Quick Links
+
+| Section | Description |
+| ------- | ----------- |
+| [Getting Started](getting-started/installation.md) | Installation, quickstart, and choosing a backend |
+| [Core Concepts](concepts/core-concepts.md) | Task Hubs, Workers, Clients, and architecture overview |
+| [Features](features/retries.md) | Retries, timers, external events, sub-orchestrations, and more |
+| [Providers](providers/durable-task-scheduler.md) | Backend storage providers (Durable Task Scheduler, Azure Storage, etc.) |
+| [Telemetry](telemetry/distributed-tracing.md) | Distributed tracing, logging, and Application Insights |
+| [Advanced Topics](advanced/middleware.md) | Middleware, entities, serialization, and testing |
+| [Samples](samples/catalog.md) | Sample projects and code patterns |
+
+## Recommended: Durable Task Scheduler with the modern .NET SDK
+
+For new projects, we recommend using the **[Durable Task Scheduler](providers/durable-task-scheduler.md)**—a fully managed Azure service that provides:
+
+- ✅ A more modern [Durable Task .NET SDK](https://github.com/microsoft/durabletask-dotnet) with improved developer experience
+- ✅ Zero infrastructure management
+- ✅ Built-in monitoring dashboard
+- ✅ Highest throughput of all backends
+- ✅ 24/7 Microsoft Azure support with SLA
+
+See [Choosing a Backend](getting-started/choosing-a-backend.md) for a full comparison of all available providers.
+
+## Support
+
+See [Support](support.md) for information about getting help with the Durable Task Framework.
diff --git a/docs/advanced/README.md b/docs/advanced/README.md
new file mode 100644
index 000000000..0101a1443
--- /dev/null
+++ b/docs/advanced/README.md
@@ -0,0 +1,15 @@
+# Advanced Topics
+
+This section covers advanced features and techniques for the Durable Task Framework.
+
+## Topics
+
+| Topic | Description |
+| ----- | ----------- |
+| [Middleware](middleware.md) | Intercept and extend orchestration/activity execution with cross-cutting concerns |
+| [Serialization](serialization.md) | Custom data converters and serialization patterns |
+| [Testing](testing.md) | Unit testing activities, integration testing with the emulator |
+| [Entities](entities.md) | Durable Entities guidance (not supported for direct use in DTFx) |
+
+> [!NOTE]
+> For Durable Entities support, see [Azure Durable Functions](https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-entities) or the [Durable Task SDK](https://github.com/microsoft/durabletask-dotnet) with [Durable Task Scheduler](../providers/durable-task-scheduler.md).
diff --git a/docs/advanced/entities.md b/docs/advanced/entities.md
new file mode 100644
index 000000000..842345794
--- /dev/null
+++ b/docs/advanced/entities.md
@@ -0,0 +1,31 @@
+# Durable Entities
+
+Durable Entities provide a way to manage small pieces of state with well-defined operations. Entities are addressable by a unique identifier and can be called from orchestrations or signaled from anywhere.
+
+## Entity Support in the Durable Task Framework
+
+> [!IMPORTANT]
+> Durable Entities are **not directly supported** for end-user development in the Durable Task Framework. The entity-related APIs that exist in this library (such as `TaskEntity`, `EntityId`, `OrchestrationEntityContext`, etc.) are low-level infrastructure components intended to support [Azure Durable Functions](https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-entities) scenarios.
+
+## Recommended Alternatives
+
+If you want to build applications that leverage the capabilities of Durable Entities, consider one of the following options:
+
+### Azure Durable Functions
+
+[Azure Durable Functions](https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-entities) provides a complete, high-level programming model for Durable Entities with full support for:
+
+- Entity classes and function-based entities
+- Calling and signaling entities from orchestrations
+- Entity state persistence and management
+- Distributed locking and critical sections
+
+### Durable Task SDK with Durable Task Scheduler
+
+The [Durable Task SDK](https://github.com/microsoft/durabletask-dotnet) used together with the [Durable Task Scheduler](../providers/durable-task-scheduler.md) provides a modern programming model with entity support. This is the recommended approach for new .NET applications that need durable entity capabilities outside of Azure Functions.
+
+## Next Steps
+
+- [Durable Task Scheduler](../providers/durable-task-scheduler.md) — Learn about the Durable Task Scheduler backend
+- [Choosing a Backend](../getting-started/choosing-a-backend.md) — Compare available backend providers
+
diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md
new file mode 100644
index 000000000..18b1e73c5
--- /dev/null
+++ b/docs/advanced/middleware.md
@@ -0,0 +1,544 @@
+# Middleware
+
+Middleware in the Durable Task Framework allows you to intercept and extend orchestration and activity execution. This is useful for cross-cutting concerns like logging, metrics, authentication, or context propagation.
+
+## Middleware Delegate Signature
+
+Middleware is registered as a delegate with the following signature:
+
+```csharp
+using DurableTask.Core.Middleware;
+
+// Middleware delegate signature
+Func, Task>
+```
+
+The `DispatchMiddlewareContext` provides access to execution context via `GetProperty()` and `SetProperty()` methods.
+
+## Orchestration Middleware
+
+### Available Context Properties
+
+Orchestration middleware can access these properties via `context.GetProperty()`:
+
+| Type | Description |
+| ---- | ----------- |
+| `OrchestrationInstance` | The orchestration instance (InstanceId, ExecutionId) |
+| `TaskOrchestration` | The orchestration implementation (may be null for out-of-process scenarios) |
+| `OrchestrationRuntimeState` | History, status, name, version, input, tags, and more |
+| `OrchestrationExecutionContext` | Contains orchestration tags |
+| `TaskOrchestrationWorkItem` | The work item being processed |
+
+### Creating Orchestration Middleware
+
+```csharp
+public static class OrchestrationLoggingMiddleware
+{
+ public static Func, Task> Create(ILogger logger)
+ {
+ return async (context, next) =>
+ {
+ var instance = context.GetProperty();
+ var runtimeState = context.GetProperty();
+ var instanceId = instance?.InstanceId ?? "unknown";
+ var orchestrationName = runtimeState?.Name ?? "unknown";
+
+ logger.LogInformation("Orchestration {Name} ({InstanceId}) starting execution",
+ orchestrationName, instanceId);
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ await next();
+ logger.LogInformation("Orchestration {Name} ({InstanceId}) completed in {ElapsedMs}ms",
+ orchestrationName, instanceId, stopwatch.ElapsedMilliseconds);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Orchestration {Name} ({InstanceId}) failed after {ElapsedMs}ms",
+ orchestrationName, instanceId, stopwatch.ElapsedMilliseconds);
+ throw;
+ }
+ };
+ }
+}
+```
+
+### Registering Orchestration Middleware
+
+```csharp
+var worker = new TaskHubWorker(orchestrationService, loggerFactory);
+
+// Add middleware using lambda - order matters (first registered = outermost)
+worker.AddOrchestrationDispatcherMiddleware(async (context, next) =>
+{
+ var instance = context.GetProperty();
+ Console.WriteLine($"Processing orchestration: {instance?.InstanceId}");
+ await next();
+});
+
+// Or use a factory method
+worker.AddOrchestrationDispatcherMiddleware(
+ OrchestrationLoggingMiddleware.Create(logger));
+
+await worker.StartAsync();
+```
+
+## Activity Middleware
+
+### Context Properties for Activities
+
+Activity middleware can access these properties via `context.GetProperty()`:
+
+| Type | Description |
+| ---- | ----------- |
+| `OrchestrationInstance` | The parent orchestration instance |
+| `TaskActivity` | The activity implementation (may be null for out-of-process scenarios) |
+| `TaskScheduledEvent` | Contains activity name, version, input, and event ID |
+| `OrchestrationExecutionContext` | Contains orchestration tags (if available) |
+
+### Creating Activity Middleware
+
+```csharp
+public static class ActivityLoggingMiddleware
+{
+ public static Func, Task> Create(ILogger logger)
+ {
+ return async (context, next) =>
+ {
+ var scheduledEvent = context.GetProperty();
+ var instance = context.GetProperty();
+ var activityName = scheduledEvent?.Name ?? "unknown";
+ var instanceId = instance?.InstanceId ?? "unknown";
+
+ logger.LogInformation("Activity {ActivityName} starting for orchestration {InstanceId}",
+ activityName, instanceId);
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ await next();
+ logger.LogInformation("Activity {ActivityName} completed in {ElapsedMs}ms",
+ activityName, stopwatch.ElapsedMilliseconds);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Activity {ActivityName} failed after {ElapsedMs}ms",
+ activityName, stopwatch.ElapsedMilliseconds);
+ throw;
+ }
+ };
+ }
+}
+```
+
+### Registering Activity Middleware
+
+```csharp
+var worker = new TaskHubWorker(orchestrationService, loggerFactory);
+
+// Add middleware using lambda
+worker.AddActivityDispatcherMiddleware(async (context, next) =>
+{
+ var scheduledEvent = context.GetProperty();
+ Console.WriteLine($"Executing activity: {scheduledEvent?.Name}");
+ await next();
+});
+
+// Or use a factory method
+worker.AddActivityDispatcherMiddleware(
+ ActivityLoggingMiddleware.Create(logger));
+
+await worker.StartAsync();
+```
+
+## Common Middleware Patterns
+
+### Metrics Collection
+
+```csharp
+worker.AddOrchestrationDispatcherMiddleware(async (context, next) =>
+{
+ var runtimeState = context.GetProperty();
+ var orchestrationName = runtimeState?.Name ?? "unknown";
+ var stopwatch = Stopwatch.StartNew();
+ var success = true;
+
+ try
+ {
+ await next();
+ }
+ catch
+ {
+ success = false;
+ throw;
+ }
+ finally
+ {
+ metrics.RecordDuration($"orchestration.{orchestrationName}.duration", stopwatch.Elapsed);
+ metrics.RecordCounter(success ? "orchestration.success" : "orchestration.failure");
+ }
+});
+```
+
+### Context Propagation (Using Tags)
+
+```csharp
+worker.AddOrchestrationDispatcherMiddleware(async (context, next) =>
+{
+ var executionContext = context.GetProperty();
+
+ // Extract tenant ID from orchestration tags
+ string tenantId = "default";
+ if (executionContext?.OrchestrationTags?.TryGetValue("TenantId", out var tenant) == true)
+ {
+ tenantId = tenant;
+ }
+
+ // Set ambient context
+ using (TenantContext.SetCurrent(tenantId))
+ {
+ await next();
+ }
+});
+```
+
+### Exception Handling Considerations
+
+> [!IMPORTANT]
+> Exceptions thrown in middleware cause the work item to be **retried**, not failed. If you want to explicitly fail an orchestration or activity, you must set the result directly.
+
+```csharp
+// CAUTION: This causes infinite retries, NOT a failure!
+worker.AddActivityDispatcherMiddleware(async (context, next) =>
+{
+ try
+ {
+ await next();
+ }
+ catch (Exception ex)
+ {
+ // Logging is fine, but re-throwing will cause retries
+ logger.LogError(ex, "Activity failed");
+ throw; // ⚠️ This causes the activity to be retried, not failed!
+ }
+});
+```
+
+To properly fail an activity from middleware, use `TaskFailureException` or set the result:
+
+```csharp
+// Option 1: Throw TaskFailureException (gets converted to TaskFailedEvent)
+worker.AddActivityDispatcherMiddleware(async (context, next) =>
+{
+ try
+ {
+ await next();
+ }
+ catch (Exception ex)
+ {
+ // This properly fails the activity and reports failure to the orchestration
+ throw new TaskFailureException(ex.Message, ex, ex.ToString());
+ }
+});
+
+// Option 2: Set the failure result directly
+worker.AddActivityDispatcherMiddleware(async (context, next) =>
+{
+ var scheduledEvent = context.GetProperty();
+
+ try
+ {
+ await next();
+ }
+ catch (Exception ex)
+ {
+ // Explicitly set a failure result
+ context.SetProperty(new ActivityExecutionResult
+ {
+ ResponseEvent = new TaskFailedEvent(
+ eventId: -1,
+ taskScheduledEventId: scheduledEvent.EventId,
+ reason: ex.Message,
+ details: ex.ToString(),
+ failureDetails: new FailureDetails(ex))
+ });
+ // Don't re-throw - we've handled the failure
+ }
+});
+```
+
+### Authentication/Authorization
+
+```csharp
+worker.AddOrchestrationDispatcherMiddleware(async (context, next) =>
+{
+ var executionContext = context.GetProperty();
+
+ string? userId = null;
+ executionContext?.OrchestrationTags?.TryGetValue("UserId", out userId);
+
+ if (string.IsNullOrEmpty(userId) ||
+ !await authService.IsAuthorizedAsync(userId, "ExecuteOrchestration"))
+ {
+ // Don't throw - that would cause retries. Instead, fail the orchestration explicitly.
+ context.SetProperty(new OrchestratorExecutionResult
+ {
+ Actions = new[]
+ {
+ new OrchestrationCompleteOrchestratorAction
+ {
+ OrchestrationStatus = OrchestrationStatus.Failed,
+ Result = $"User {userId ?? "unknown"} is not authorized to execute orchestrations",
+ FailureDetails = new FailureDetails(
+ errorType: "UnauthorizedAccessException",
+ errorMessage: $"User {userId ?? "unknown"} is not authorized",
+ stackTrace: null,
+ innerFailure: null,
+ isNonRetriable: true)
+ }
+ }
+ });
+ return; // Don't call next()
+ }
+
+ await next();
+});
+```
+
+## Middleware Context
+
+### Accessing Built-in Properties
+
+```csharp
+// For orchestration middleware
+worker.AddOrchestrationDispatcherMiddleware(async (context, next) =>
+{
+ // Core identification
+ var instance = context.GetProperty();
+ var instanceId = instance?.InstanceId;
+ var executionId = instance?.ExecutionId;
+
+ // Orchestration metadata
+ var runtimeState = context.GetProperty();
+ var orchestrationName = runtimeState?.Name;
+ var orchestrationVersion = runtimeState?.Version;
+ var input = runtimeState?.Input;
+ var status = runtimeState?.OrchestrationStatus;
+
+ // Tags
+ var executionContext = context.GetProperty();
+ var tags = executionContext?.OrchestrationTags;
+
+ // The orchestration implementation (may be null for out-of-process execution)
+ var orchestration = context.GetProperty();
+
+ await next();
+});
+
+// For activity middleware
+worker.AddActivityDispatcherMiddleware(async (context, next) =>
+{
+ // Parent orchestration instance
+ var instance = context.GetProperty();
+
+ // Activity details from the scheduled event
+ var scheduledEvent = context.GetProperty();
+ var activityName = scheduledEvent?.Name;
+ var activityVersion = scheduledEvent?.Version;
+ var activityInput = scheduledEvent?.Input;
+ var eventId = scheduledEvent?.EventId;
+
+ // The activity implementation (may be null for out-of-process execution)
+ var activity = context.GetProperty();
+
+ await next();
+});
+```
+
+### Setting Custom Properties
+
+```csharp
+// First middleware sets a property
+worker.AddOrchestrationDispatcherMiddleware(async (context, next) =>
+{
+ // Set a named property for downstream middleware
+ context.SetProperty("CorrelationId", Guid.NewGuid().ToString());
+ await next();
+});
+
+// Downstream middleware reads the property
+worker.AddOrchestrationDispatcherMiddleware(async (context, next) =>
+{
+ var correlationId = context.GetProperty("CorrelationId");
+ Console.WriteLine($"Correlation ID: {correlationId}");
+ await next();
+});
+```
+
+## Middleware Ordering
+
+Middleware executes in a pipeline. The order of registration determines execution order:
+
+```csharp
+// Registration order
+worker.AddOrchestrationDispatcherMiddleware(AuthMiddleware); // 1st registered
+worker.AddOrchestrationDispatcherMiddleware(LoggingMiddleware); // 2nd registered
+worker.AddOrchestrationDispatcherMiddleware(MetricsMiddleware); // 3rd registered
+
+// Execution order (onion model):
+// AuthMiddleware →
+// LoggingMiddleware →
+// MetricsMiddleware →
+// [Orchestration executes]
+// ← MetricsMiddleware returns
+// ← LoggingMiddleware returns
+// ← AuthMiddleware returns
+```
+
+## Best Practices
+
+### 1. Keep Middleware Focused
+
+Each middleware should have a single responsibility:
+
+```csharp
+// Good - single responsibility with factory methods
+public static class LoggingMiddleware
+{
+ public static Func, Task> Create(ILogger logger) => /* logging only */;
+}
+
+public static class MetricsMiddleware
+{
+ public static Func, Task> Create(IMetrics metrics) => /* metrics only */;
+}
+
+// Avoid combining multiple concerns in one middleware
+```
+
+### 2. Understand Exception Behavior
+
+Exceptions thrown in middleware cause **retries**, not failures:
+
+```csharp
+// For activities: Use TaskFailureException to signal failure to orchestration
+worker.AddActivityDispatcherMiddleware(async (context, next) =>
+{
+ try
+ {
+ await next();
+ }
+ catch (MyValidationException ex)
+ {
+ // Convert to TaskFailureException to properly fail the activity
+ throw new TaskFailureException(ex.Message, ex, ex.ToString());
+ }
+ // Other exceptions will cause retries
+});
+
+// For orchestrations: Set result with failed status
+worker.AddOrchestrationDispatcherMiddleware(async (context, next) =>
+{
+ try
+ {
+ await next();
+ }
+ catch (Exception ex) when (ShouldFailOrchestration(ex))
+ {
+ context.SetProperty(new OrchestratorExecutionResult
+ {
+ Actions = new[]
+ {
+ new OrchestrationCompleteOrchestratorAction
+ {
+ OrchestrationStatus = OrchestrationStatus.Failed,
+ Result = ex.Message,
+ FailureDetails = new FailureDetails(ex)
+ }
+ }
+ });
+ // Don't re-throw - we've handled the failure
+ }
+});
+```
+
+### 3. Use Dependency Injection Patterns
+
+Capture dependencies via closures or factory methods:
+
+```csharp
+// Using closures
+public static Func, Task> CreateTelemetryMiddleware(
+ TelemetryClient telemetry,
+ ILogger logger)
+{
+ return async (context, next) =>
+ {
+ var instance = context.GetProperty();
+ telemetry.TrackEvent("OrchestrationStarted",
+ new Dictionary { ["InstanceId"] = instance?.InstanceId });
+
+ await next();
+ };
+}
+
+// Registration
+worker.AddOrchestrationDispatcherMiddleware(
+ CreateTelemetryMiddleware(telemetryClient, logger));
+```
+
+### 4. Intercepting Execution Results
+
+Middleware can intercept and modify execution results:
+
+```csharp
+// For orchestrations - intercept or provide custom results
+worker.AddOrchestrationDispatcherMiddleware(async (context, next) =>
+{
+ await next();
+
+ // After execution, you can read the result
+ var result = context.GetProperty();
+ // Inspect result.Actions, result.CustomStatus, etc.
+});
+
+// For activities - intercept or provide custom results
+worker.AddActivityDispatcherMiddleware(async (context, next) =>
+{
+ await next();
+
+ // After execution, you can read the result
+ var result = context.GetProperty();
+ // Inspect result.ResponseEvent
+});
+```
+
+### 5. Out-of-Process Execution
+
+Middleware can completely replace execution for out-of-process scenarios:
+
+```csharp
+worker.AddOrchestrationDispatcherMiddleware(async (context, next) =>
+{
+ var runtimeState = context.GetProperty();
+
+ // Execute orchestration out-of-process and get result
+ var actions = await ExecuteOutOfProcessAsync(runtimeState);
+
+ // Set the result directly - the default handler will be skipped
+ context.SetProperty(new OrchestratorExecutionResult
+ {
+ Actions = actions,
+ CustomStatus = "Executed out-of-process"
+ });
+
+ // Don't call next() if you're providing the result yourself
+});
+```
+
+## Next Steps
+
+- [Entities](entities.md) — Durable Entities pattern
+- [Serialization](serialization.md) — Custom data converters
+- [Testing](testing.md) — Testing orchestrations
diff --git a/docs/advanced/serialization.md b/docs/advanced/serialization.md
new file mode 100644
index 000000000..3faabfcd1
--- /dev/null
+++ b/docs/advanced/serialization.md
@@ -0,0 +1,477 @@
+# Serialization
+
+The Durable Task Framework uses serialization to persist orchestration state, activity inputs/outputs, and messages between components. Understanding serialization is essential for correct orchestration behavior.
+
+## Default Serialization
+
+By default, DTFx uses JSON serialization via Newtonsoft.Json (Json.NET).
+
+The default `JsonDataConverter` uses these settings:
+
+```csharp
+new JsonSerializerSettings
+{
+ TypeNameHandling = TypeNameHandling.Objects,
+ DateParseHandling = DateParseHandling.None,
+ SerializationBinder = new PackageUpgradeSerializationBinder()
+}
+```
+
+**Key behaviors:**
+
+- `TypeNameHandling.Objects` — Includes type information for polymorphic deserialization
+- `DateParseHandling.None` — Dates are not automatically parsed (preserves as strings)
+- `PackageUpgradeSerializationBinder` — Handles type name migration across package versions
+
+## Custom DataConverter
+
+### Creating a Custom Converter
+
+Extend the abstract `DataConverter` class:
+
+```csharp
+using DurableTask.Core.Serializing;
+using System.Text.Json;
+
+public class SystemTextJsonDataConverter : DataConverter
+{
+ private readonly JsonSerializerOptions _options;
+
+ public SystemTextJsonDataConverter()
+ {
+ _options = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = false
+ };
+ }
+
+ public override string Serialize(object value)
+ {
+ return Serialize(value, formatted: false);
+ }
+
+ public override string Serialize(object value, bool formatted)
+ {
+ if (value == null)
+ {
+ return null;
+ }
+
+ var options = formatted
+ ? new JsonSerializerOptions(_options) { WriteIndented = true }
+ : _options;
+
+ return JsonSerializer.Serialize(value, options);
+ }
+
+ public override object Deserialize(string data, Type objectType)
+ {
+ if (string.IsNullOrEmpty(data))
+ {
+ return null;
+ }
+
+ return JsonSerializer.Deserialize(data, objectType, _options);
+ }
+}
+```
+
+### Custom JsonSerializerSettings
+
+For custom Newtonsoft.Json settings, pass settings to the constructor:
+
+```csharp
+var settings = new JsonSerializerSettings
+{
+ TypeNameHandling = TypeNameHandling.Auto,
+ NullValueHandling = NullValueHandling.Ignore,
+ DateFormatHandling = DateFormatHandling.IsoDateFormat,
+ ContractResolver = new CamelCasePropertyNamesContractResolver()
+};
+
+var converter = new JsonDataConverter(settings);
+```
+
+### Using Custom Converters
+
+Set custom converters on the `OrchestrationContext`:
+
+```csharp
+public class MyOrchestration : TaskOrchestration
+{
+ public override async Task RunTask(
+ OrchestrationContext context,
+ Input input)
+ {
+ // Use custom converter for messages (must be JsonDataConverter or subclass)
+ context.MessageDataConverter = new JsonDataConverter(customSettings);
+
+ // Use custom converter for errors (must be JsonDataConverter or subclass)
+ context.ErrorDataConverter = new JsonDataConverter(customSettings);
+
+ // Now all serialization uses custom converter
+ var result = await context.ScheduleTask