From 1ca71451a91eea16895557429573e811ee222a2a Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:56:24 -0700 Subject: [PATCH 1/8] Add ReplaySafeLoggerFactory property for context-wrapping scenarios Add a public virtual ReplaySafeLoggerFactory property to TaskOrchestrationContext that returns an ILoggerFactory producing replay-safe loggers. This enables context-wrapping scenarios (e.g., cancellable orchestrator wrappers) while preserving the pit-of-success design that keeps LoggerFactory protected. Fixes #497 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 ++ src/Abstractions/TaskOrchestrationContext.cs | 43 ++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 885c291cd..b3159c918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## Unreleased +- Add `ReplaySafeLoggerFactory` public property to `TaskOrchestrationContext` to enable context-wrapping scenarios while preserving replay-safe logging guarantees ([#497](https://github.com/microsoft/durabletask-dotnet/issues/497)) + ## v1.22.0 - Changing the default dedupe statuses behavior by sophiatev ([#622](https://github.com/microsoft/durabletask-dotnet/pull/622)) - Bump Analyzers package version to 1.22.0 stable release (from 0.3.0) diff --git a/src/Abstractions/TaskOrchestrationContext.cs b/src/Abstractions/TaskOrchestrationContext.cs index 9cf5fe7e3..6d0f84ed5 100644 --- a/src/Abstractions/TaskOrchestrationContext.cs +++ b/src/Abstractions/TaskOrchestrationContext.cs @@ -13,6 +13,8 @@ namespace Microsoft.DurableTask; /// public abstract class TaskOrchestrationContext { + ILoggerFactory? replaySafeLoggerFactory; + /// /// Gets the name of the task orchestration. /// @@ -81,6 +83,25 @@ public abstract class TaskOrchestrationContext /// protected abstract ILoggerFactory LoggerFactory { get; } + /// + /// Gets an whose loggers are replay-safe, meaning they suppress log + /// output during orchestration replay. This is the recommended way to expose logger functionality + /// when wrapping a instance. + /// + /// + /// + /// Loggers created by this factory automatically check and suppress + /// duplicate log messages during replay. This is equivalent to calling + /// for each logger category. + /// + /// + /// Context wrapper implementations can delegate to this property + /// on the inner context: protected override ILoggerFactory LoggerFactory => inner.ReplaySafeLoggerFactory; + /// + /// + public virtual ILoggerFactory ReplaySafeLoggerFactory + => this.replaySafeLoggerFactory ??= new ReplaySafeLoggerFactoryImpl(this); + /// /// Gets the deserialized input of the orchestrator. /// @@ -473,4 +494,26 @@ public void Log( } } } + + sealed class ReplaySafeLoggerFactoryImpl : ILoggerFactory + { + readonly TaskOrchestrationContext context; + + internal ReplaySafeLoggerFactoryImpl(TaskOrchestrationContext context) + { + this.context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public ILogger CreateLogger(string categoryName) + => this.context.CreateReplaySafeLogger(categoryName); + + public void AddProvider(ILoggerProvider provider) + => throw new NotSupportedException( + "Adding providers to the replay-safe logger factory is not supported."); + + public void Dispose() + { + // No-op: this wrapper does not own the underlying logger factory. + } + } } From 7f3c33874ebc9eaac438ae03eda24ca30f4f08ce Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:40:42 -0700 Subject: [PATCH 2/8] Add ReplaySafeLoggerFactory sample and tests Add a new ReplaySafeLoggerFactory sample that demonstrates wrapping TaskOrchestrationContext while preserving replay-safe logging, along with targeted abstraction tests for the new factory behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.DurableTask.sln | 40 +++ README.md | 2 + .../ReplaySafeLoggerFactorySample/Program.cs | 222 +++++++++++++++++ .../ReplaySafeLoggerFactorySample/README.md | 78 ++++++ .../ReplaySafeLoggerFactorySample.csproj | 28 +++ ...tionContextReplaySafeLoggerFactoryTests.cs | 231 ++++++++++++++++++ 6 files changed, 601 insertions(+) create mode 100644 samples/ReplaySafeLoggerFactorySample/Program.cs create mode 100644 samples/ReplaySafeLoggerFactorySample/README.md create mode 100644 samples/ReplaySafeLoggerFactorySample/ReplaySafeLoggerFactorySample.csproj create mode 100644 test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index ea7d17973..795992c89 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -111,18 +111,41 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExportHistory.Tests", "test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DistributedTracingSample", "samples\DistributedTracingSample\DistributedTracingSample.csproj", "{4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReplaySafeLoggerFactorySample", "samples\ReplaySafeLoggerFactorySample\ReplaySafeLoggerFactorySample.csproj", "{8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Grpc", "Grpc", "{51A52603-541D-DE3F-2825-C80F9EE6C532}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{D4587EC0-1B16-8420-7502-A967139249D4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Grpc", "Grpc", "{3B8F957E-7773-4C0C-ACD7-91A1591D9312}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{53193780-CD18-2643-6953-C26F59EAEDF5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x64.ActiveCfg = Debug|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x64.Build.0 = Debug|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x86.ActiveCfg = Debug|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x86.Build.0 = Debug|Any CPU {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|Any CPU.Build.0 = Release|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x64.ActiveCfg = Release|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x64.Build.0 = Release|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x86.ActiveCfg = Release|Any CPU + {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x86.Build.0 = Release|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x64.ActiveCfg = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x64.Build.0 = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x86.ActiveCfg = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x86.Build.0 = Debug|Any CPU @@ -660,6 +683,18 @@ Global {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8}.Release|x64.Build.0 = Release|Any CPU {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8}.Release|x86.ActiveCfg = Release|Any CPU {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8}.Release|x86.Build.0 = Release|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Debug|x64.ActiveCfg = Debug|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Debug|x64.Build.0 = Debug|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Debug|x86.ActiveCfg = Debug|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Debug|x86.Build.0 = Debug|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|Any CPU.Build.0 = Release|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x64.ActiveCfg = Release|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x64.Build.0 = Release|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x86.ActiveCfg = Release|Any CPU + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -715,6 +750,11 @@ Global {354CE69B-78DB-9B29-C67E-0DBB862C7A65} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B} {05C9EBA6-7221-D458-47D6-DA457C2F893B} = {E5637F81-2FB9-4CD7-900D-455363B142A7} {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {51A52603-541D-DE3F-2825-C80F9EE6C532} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} + {D4587EC0-1B16-8420-7502-A967139249D4} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} + {3B8F957E-7773-4C0C-ACD7-91A1591D9312} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} + {53193780-CD18-2643-6953-C26F59EAEDF5} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/README.md b/README.md index 7226f2011..70dbe73bf 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,8 @@ The Durable Task Scheduler for Azure Functions is a managed backend that is curr This SDK can also be used with the Durable Task Scheduler directly, without any Durable Functions dependency. To get started, sign up for the [Durable Task Scheduler private preview](https://techcommunity.microsoft.com/blog/appsonazureblog/announcing-limited-early-access-of-the-durable-task-scheduler-for-azure-durable-/4286526) and follow the instructions to create a new Durable Task Scheduler instance. Once granted access to the private preview GitHub repository, you can find samples and documentation for getting started [here](https://github.com/Azure/Azure-Functions-Durable-Task-Scheduler-Private-Preview/tree/main/samples/portable-sdk/dotnet/AspNetWebApp#readme). +For a sample that demonstrates wrapping `TaskOrchestrationContext` while preserving replay-safe logging, see [samples/ReplaySafeLoggerFactorySample](samples/ReplaySafeLoggerFactorySample/README.md). + ## Obtaining the Protobuf definitions This project utilizes protobuf definitions from [durabletask-protobuf](https://github.com/microsoft/durabletask-protobuf), which are copied (vendored) into this repository under the `src/Grpc` directory. See the corresponding [README.md](./src/Grpc/README.md) for more information about how to update the protobuf definitions. diff --git a/samples/ReplaySafeLoggerFactorySample/Program.cs b/samples/ReplaySafeLoggerFactorySample/Program.cs new file mode 100644 index 000000000..3824f1b72 --- /dev/null +++ b/samples/ReplaySafeLoggerFactorySample/Program.cs @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates how to wrap TaskOrchestrationContext and delegate LoggerFactory +// to inner.ReplaySafeLoggerFactory so wrapper helpers can log without breaking replay safety. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Entities; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ReplaySafeLoggerFactorySample; + +internal static class Program +{ + private static async Task Main(string[] args) + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + + string? schedulerConnectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING"); + bool useScheduler = !string.IsNullOrWhiteSpace(schedulerConnectionString); + + ConfigureDurableTask(builder, useScheduler, schedulerConnectionString); + + IHost host = builder.Build(); + await host.StartAsync(); + + try + { + await using DurableTaskClient client = host.Services.GetRequiredService(); + + Console.WriteLine("ReplaySafeLoggerFactory Sample"); + Console.WriteLine("================================"); + Console.WriteLine(useScheduler + ? "Configured to use Durable Task Scheduler (DTS)." + : "Configured to use local gRPC. (Set DURABLE_TASK_SCHEDULER_CONNECTION_STRING to use DTS.)"); + Console.WriteLine(); + + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + nameof(ReplaySafeLoggingOrchestration), + input: "Seattle"); + + Console.WriteLine($"Started orchestration instance: {instanceId}"); + + using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(60)); + OrchestrationMetadata result = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true, + timeoutCts.Token); + + if (result.RuntimeStatus != OrchestrationRuntimeStatus.Completed) + { + throw new InvalidOperationException( + $"Expected '{nameof(OrchestrationRuntimeStatus.Completed)}' but got '{result.RuntimeStatus}'."); + } + + Console.WriteLine($"Result: {result.ReadOutputAs()}"); + Console.WriteLine(); + Console.WriteLine( + "The wrapper delegates LoggerFactory to inner.ReplaySafeLoggerFactory, " + + "so wrapper-level logging stays replay-safe."); + } + finally + { + await host.StopAsync(); + } + } + + private static void ConfigureDurableTask( + HostApplicationBuilder builder, + bool useScheduler, + string? schedulerConnectionString) + { + if (useScheduler) + { + builder.Services.AddDurableTaskClient(clientBuilder => clientBuilder.UseDurableTaskScheduler(schedulerConnectionString!)); + + builder.Services.AddDurableTaskWorker(workerBuilder => + { + workerBuilder.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); + + workerBuilder.UseDurableTaskScheduler(schedulerConnectionString!); + }); + } + else + { + builder.Services.AddDurableTaskClient().UseGrpc(); + + builder.Services.AddDurableTaskWorker() + .AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }) + .UseGrpc(); + } + } +} + +[DurableTask(nameof(ReplaySafeLoggingOrchestration))] +internal sealed class ReplaySafeLoggingOrchestration : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + LoggingTaskOrchestrationContext wrappedContext = new(context); + ILogger logger = wrappedContext.CreateLogger(); + + logger.LogInformation("Wrapping orchestration context for instance {InstanceId}.", wrappedContext.InstanceId); + + string greeting = await wrappedContext.CallActivityWithLoggingAsync(nameof(SayHelloActivity), input); + + logger.LogInformation("Returning activity result for {InstanceId}.", wrappedContext.InstanceId); + return greeting; + } +} + +[DurableTask(nameof(SayHelloActivity))] +internal sealed class SayHelloActivity : TaskActivity +{ + readonly ILogger logger; + + public SayHelloActivity(ILoggerFactory loggerFactory) + { + this.logger = loggerFactory.CreateLogger(); + } + + public override Task RunAsync(TaskActivityContext context, string input) + { + this.logger.LogInformation("Generating a greeting for {Name}.", input); + return Task.FromResult( + $"Hello, {input}! This orchestration used ReplaySafeLoggerFactory to keep wrapper logging replay-safe."); + } +} + +internal sealed class LoggingTaskOrchestrationContext : TaskOrchestrationContext +{ + readonly TaskOrchestrationContext innerContext; + + public LoggingTaskOrchestrationContext(TaskOrchestrationContext innerContext) + { + this.innerContext = innerContext ?? throw new ArgumentNullException(nameof(innerContext)); + } + + public override TaskName Name => this.innerContext.Name; + + public override string InstanceId => this.innerContext.InstanceId; + + public override ParentOrchestrationInstance? Parent => this.innerContext.Parent; + + public override DateTime CurrentUtcDateTime => this.innerContext.CurrentUtcDateTime; + + public override bool IsReplaying => this.innerContext.IsReplaying; + + public override string Version => this.innerContext.Version; + + public override IReadOnlyDictionary Properties => this.innerContext.Properties; + + public override TaskOrchestrationEntityFeature Entities => this.innerContext.Entities; + + protected override ILoggerFactory LoggerFactory => this.innerContext.ReplaySafeLoggerFactory; + + public ILogger CreateLogger() + => this.CreateReplaySafeLogger(); + + public async Task CallActivityWithLoggingAsync( + TaskName name, + object? input = null, + TaskOptions? options = null) + { + ILogger logger = this.CreateReplaySafeLogger(); + logger.LogInformation("Calling activity {ActivityName} for instance {InstanceId}.", name.Name, this.InstanceId); + + TResult result = await this.CallActivityAsync(name, input, options); + + logger.LogInformation("Activity {ActivityName} completed for instance {InstanceId}.", name.Name, this.InstanceId); + return result; + } + + public override T GetInput() + where T : default + => this.innerContext.GetInput()!; + + public override Task CallActivityAsync( + TaskName name, + object? input = null, + TaskOptions? options = null) + => this.innerContext.CallActivityAsync(name, input, options); + + public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) + => this.innerContext.CreateTimer(fireAt, cancellationToken); + + public override Task WaitForExternalEvent(string eventName, CancellationToken cancellationToken = default) + => this.innerContext.WaitForExternalEvent(eventName, cancellationToken); + + public override void SendEvent(string instanceId, string eventName, object payload) + => this.innerContext.SendEvent(instanceId, eventName, payload); + + public override void SetCustomStatus(object? customStatus) + => this.innerContext.SetCustomStatus(customStatus); + + public override Task CallSubOrchestratorAsync( + TaskName orchestratorName, + object? input = null, + TaskOptions? options = null) + => this.innerContext.CallSubOrchestratorAsync(orchestratorName, input, options); + + public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) + => this.innerContext.ContinueAsNew(newInput, preserveUnprocessedEvents); + + public override Guid NewGuid() + => this.innerContext.NewGuid(); +} diff --git a/samples/ReplaySafeLoggerFactorySample/README.md b/samples/ReplaySafeLoggerFactorySample/README.md new file mode 100644 index 000000000..8b1e74ff7 --- /dev/null +++ b/samples/ReplaySafeLoggerFactorySample/README.md @@ -0,0 +1,78 @@ +# Replay-Safe Logger Factory Sample + +This sample demonstrates how to wrap `TaskOrchestrationContext` and use the new `ReplaySafeLoggerFactory` property to preserve replay-safe logging. + +## Overview + +When you build helper libraries or decorators around `TaskOrchestrationContext`, C# protected access rules prevent you from delegating the protected `LoggerFactory` property from an inner context. This sample shows the recommended pattern: + +```csharp +protected override ILoggerFactory LoggerFactory => innerContext.ReplaySafeLoggerFactory; +``` + +That approach keeps wrapper-level logging replay-safe while still allowing the wrapper to add orchestration-specific helper methods. + +## What This Sample Does + +1. Defines a `LoggingTaskOrchestrationContext` wrapper around `TaskOrchestrationContext` +2. Delegates the wrapper's `LoggerFactory` to `innerContext.ReplaySafeLoggerFactory` +3. Adds a `CallActivityWithLoggingAsync` helper that logs before and after an activity call +4. Runs an orchestration that uses the wrapper and completes with a simple greeting + +## Running the Sample + +This sample can run against either: + +1. **Durable Task Scheduler (DTS)**: set the `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` environment variable. +2. **Local gRPC endpoint**: if the environment variable is not set, the sample uses the default local gRPC configuration. + +### DTS + +Set `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` and run the sample. + +```cmd +set DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://...;TaskHub=...;Authentication=...; +dotnet run --project samples/ReplaySafeLoggerFactorySample/ReplaySafeLoggerFactorySample.csproj +``` + +```bash +export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=https://...;TaskHub=...;Authentication=...;" +dotnet run --project samples/ReplaySafeLoggerFactorySample/ReplaySafeLoggerFactorySample.csproj +``` + +## Expected Output + +The sample: + +1. Starts a simple orchestration +2. Wraps the orchestration context +3. Calls an activity through a wrapper helper that uses replay-safe logging +4. Prints the orchestration result + +## Code Structure + +- `Program.cs`: Contains the host setup, orchestration, activity, and wrapper context + +## Key Code Snippet + +```csharp +internal sealed class LoggingTaskOrchestrationContext : TaskOrchestrationContext +{ + protected override ILoggerFactory LoggerFactory => this.innerContext.ReplaySafeLoggerFactory; + + public async Task CallActivityWithLoggingAsync(TaskName name, object? input = null) + { + ILogger logger = this.CreateReplaySafeLogger(); + logger.LogInformation("Calling activity {ActivityName}.", name.Name); + TResult result = await this.CallActivityAsync(name, input); + logger.LogInformation("Activity {ActivityName} completed.", name.Name); + return result; + } +} +``` + +## Notes + +- The key design point is that the raw `LoggerFactory` remains protected on `TaskOrchestrationContext` +- `ReplaySafeLoggerFactory` exists specifically for wrapper and delegation scenarios like this one +- The wrapper shown here forwards the core abstract members needed by the sample; real wrappers can forward additional members as needed diff --git a/samples/ReplaySafeLoggerFactorySample/ReplaySafeLoggerFactorySample.csproj b/samples/ReplaySafeLoggerFactorySample/ReplaySafeLoggerFactorySample.csproj new file mode 100644 index 000000000..ebc0466f2 --- /dev/null +++ b/samples/ReplaySafeLoggerFactorySample/ReplaySafeLoggerFactorySample.csproj @@ -0,0 +1,28 @@ + + + + Exe + net6.0;net8.0;net10.0 + enable + + + + + + + + + + + + + + + + + + + diff --git a/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs b/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs new file mode 100644 index 000000000..92e894be8 --- /dev/null +++ b/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.Tests; + +public class TaskOrchestrationContextReplaySafeLoggerFactoryTests +{ + [Fact] + public void ReplaySafeLoggerFactory_ReturnsCachedFactoryInstance() + { + // Arrange + TrackingLoggerProvider provider = new(); + TrackingLoggerFactory loggerFactory = new(provider); + TestTaskOrchestrationContext context = new(loggerFactory, isReplaying: false); + + // Act + ILoggerFactory firstFactory = context.ReplaySafeLoggerFactory; + ILoggerFactory secondFactory = context.ReplaySafeLoggerFactory; + + // Assert + secondFactory.Should().BeSameAs(firstFactory); + } + + [Fact] + public void ReplaySafeLoggerFactory_CreateLogger_SuppressesLogsDuringReplay() + { + // Arrange + TrackingLoggerProvider provider = new(); + TrackingLoggerFactory loggerFactory = new(provider); + TestTaskOrchestrationContext context = new(loggerFactory, isReplaying: true); + ILogger logger = context.ReplaySafeLoggerFactory.CreateLogger("ReplaySafe"); + + // Act + logger.LogInformation("This log should be suppressed."); + + // Assert + provider.Entries.Should().BeEmpty(); + } + + [Fact] + public void ReplaySafeLoggerFactory_CreateLogger_WritesLogsWhenNotReplaying() + { + // Arrange + TrackingLoggerProvider provider = new(); + TrackingLoggerFactory loggerFactory = new(provider); + TestTaskOrchestrationContext context = new(loggerFactory, isReplaying: false); + ILogger logger = context.ReplaySafeLoggerFactory.CreateLogger("ReplaySafe"); + + // Act + logger.LogInformation("This log should be written."); + + // Assert + provider.Entries.Should().ContainSingle(entry => + entry.CategoryName == "ReplaySafe" && + entry.Message.Contains("This log should be written.", StringComparison.Ordinal)); + } + + [Fact] + public void ReplaySafeLoggerFactory_AddProvider_ThrowsWithoutMutatingUnderlyingFactory() + { + // Arrange + TrackingLoggerProvider provider = new(); + TrackingLoggerFactory loggerFactory = new(provider); + TestTaskOrchestrationContext context = new(loggerFactory, isReplaying: false); + Mock additionalProvider = new(); + + // Act + Action act = () => context.ReplaySafeLoggerFactory.AddProvider(additionalProvider.Object); + + // Assert + act.Should().Throw() + .WithMessage("*replay-safe logger factory*not supported*"); + loggerFactory.AddProviderCallCount.Should().Be(0); + } + + [Fact] + public void ReplaySafeLoggerFactory_Dispose_DoesNotDisposeUnderlyingFactory() + { + // Arrange + TrackingLoggerProvider provider = new(); + TrackingLoggerFactory loggerFactory = new(provider); + TestTaskOrchestrationContext context = new(loggerFactory, isReplaying: false); + + // Act + context.ReplaySafeLoggerFactory.Dispose(); + + // Assert + loggerFactory.DisposeCallCount.Should().Be(0); + } + + sealed class TestTaskOrchestrationContext : TaskOrchestrationContext + { + readonly ILoggerFactory loggerFactory; + readonly bool isReplaying; + + public TestTaskOrchestrationContext(ILoggerFactory loggerFactory, bool isReplaying) + { + this.loggerFactory = loggerFactory; + this.isReplaying = isReplaying; + } + + public override TaskName Name => default; + + public override string InstanceId => "test-instance"; + + public override ParentOrchestrationInstance? Parent => null; + + public override DateTime CurrentUtcDateTime => DateTime.UnixEpoch; + + public override bool IsReplaying => this.isReplaying; + + public override IReadOnlyDictionary Properties => new Dictionary(); + + public override TaskOrchestrationEntityFeature Entities => throw new NotSupportedException(); + + protected override ILoggerFactory LoggerFactory => this.loggerFactory; + + public override T GetInput() + where T : default + => default!; + + public override Task CallActivityAsync(TaskName name, object? input = null, TaskOptions? options = null) + => throw new NotImplementedException(); + + public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public override Task WaitForExternalEvent(string eventName, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public override void SendEvent(string instanceId, string eventName, object payload) + => throw new NotImplementedException(); + + public override void SetCustomStatus(object? customStatus) + => throw new NotImplementedException(); + + public override Task CallSubOrchestratorAsync( + TaskName orchestratorName, + object? input = null, + TaskOptions? options = null) + => throw new NotImplementedException(); + + public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) + => throw new NotImplementedException(); + + public override Guid NewGuid() + => throw new NotImplementedException(); + } + + sealed class TrackingLoggerFactory : ILoggerFactory + { + readonly TrackingLoggerProvider provider; + + public TrackingLoggerFactory(TrackingLoggerProvider provider) + { + this.provider = provider; + } + + public int AddProviderCallCount { get; private set; } + + public int DisposeCallCount { get; private set; } + + public void AddProvider(ILoggerProvider provider) + { + this.AddProviderCallCount++; + } + + public ILogger CreateLogger(string categoryName) + => this.provider.CreateLogger(categoryName); + + public void Dispose() + { + this.DisposeCallCount++; + } + } + + sealed class TrackingLoggerProvider : ILoggerProvider + { + public List Entries { get; } = new(); + + public ILogger CreateLogger(string categoryName) + => new TrackingLogger(categoryName, this.Entries); + + public void Dispose() + { + } + } + + sealed class TrackingLogger : ILogger + { + readonly string categoryName; + readonly List entries; + + public TrackingLogger(string categoryName, List entries) + { + this.categoryName = categoryName; + this.entries = entries; + } + + public IDisposable? BeginScope(TState state) + where TState : notnull + => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) + => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + this.entries.Add(new LogEntry(this.categoryName, logLevel, formatter(state, exception))); + } + } + + sealed record LogEntry(string CategoryName, LogLevel LogLevel, string Message); + + sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } +} From f888b35888589df0a194762bed75e2e548cff004 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:59:04 -0700 Subject: [PATCH 3/8] Address PR feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 -- src/Abstractions/TaskOrchestrationContext.cs | 18 ++++++++++-------- ...ationContextReplaySafeLoggerFactoryTests.cs | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 70dbe73bf..7226f2011 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,6 @@ The Durable Task Scheduler for Azure Functions is a managed backend that is curr This SDK can also be used with the Durable Task Scheduler directly, without any Durable Functions dependency. To get started, sign up for the [Durable Task Scheduler private preview](https://techcommunity.microsoft.com/blog/appsonazureblog/announcing-limited-early-access-of-the-durable-task-scheduler-for-azure-durable-/4286526) and follow the instructions to create a new Durable Task Scheduler instance. Once granted access to the private preview GitHub repository, you can find samples and documentation for getting started [here](https://github.com/Azure/Azure-Functions-Durable-Task-Scheduler-Private-Preview/tree/main/samples/portable-sdk/dotnet/AspNetWebApp#readme). -For a sample that demonstrates wrapping `TaskOrchestrationContext` while preserving replay-safe logging, see [samples/ReplaySafeLoggerFactorySample](samples/ReplaySafeLoggerFactorySample/README.md). - ## Obtaining the Protobuf definitions This project utilizes protobuf definitions from [durabletask-protobuf](https://github.com/microsoft/durabletask-protobuf), which are copied (vendored) into this repository under the `src/Grpc` directory. See the corresponding [README.md](./src/Grpc/README.md) for more information about how to update the protobuf definitions. diff --git a/src/Abstractions/TaskOrchestrationContext.cs b/src/Abstractions/TaskOrchestrationContext.cs index 6d0f84ed5..4bc404ac0 100644 --- a/src/Abstractions/TaskOrchestrationContext.cs +++ b/src/Abstractions/TaskOrchestrationContext.cs @@ -78,11 +78,6 @@ public abstract class TaskOrchestrationContext public virtual TaskOrchestrationEntityFeature Entities => throw new NotSupportedException($"Durable entities are not supported by {this.GetType()}."); - /// - /// Gets the logger factory for this context. - /// - protected abstract ILoggerFactory LoggerFactory { get; } - /// /// Gets an whose loggers are replay-safe, meaning they suppress log /// output during orchestration replay. This is the recommended way to expose logger functionality @@ -96,12 +91,17 @@ public abstract class TaskOrchestrationContext /// /// /// Context wrapper implementations can delegate to this property - /// on the inner context: protected override ILoggerFactory LoggerFactory => inner.ReplaySafeLoggerFactory; + /// on the inner context: protected override ILoggerFactory LoggerFactory => inner.ReplaySafeLoggerFactory;. /// /// public virtual ILoggerFactory ReplaySafeLoggerFactory => this.replaySafeLoggerFactory ??= new ReplaySafeLoggerFactoryImpl(this); + /// + /// Gets the logger factory for this context. + /// + protected abstract ILoggerFactory LoggerFactory { get; } + /// /// Gets the deserialized input of the orchestrator. /// @@ -466,7 +466,7 @@ public virtual int CompareVersionTo(string version) return TaskOrchestrationVersioningUtils.CompareVersions(this.Version, version); } - class ReplaySafeLogger : ILogger + sealed class ReplaySafeLogger : ILogger { readonly TaskOrchestrationContext context; readonly ILogger logger; @@ -477,7 +477,9 @@ internal ReplaySafeLogger(TaskOrchestrationContext context, ILogger logger) this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public IDisposable BeginScope(TState state) => this.logger.BeginScope(state); + public IDisposable? BeginScope(TState state) + where TState : notnull + => this.logger.BeginScope(state); public bool IsEnabled(LogLevel logLevel) => this.logger.IsEnabled(logLevel); diff --git a/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs b/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs index 92e894be8..61f9f1084 100644 --- a/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs +++ b/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs @@ -163,7 +163,7 @@ public TrackingLoggerFactory(TrackingLoggerProvider provider) public int DisposeCallCount { get; private set; } - public void AddProvider(ILoggerProvider provider) + public void AddProvider(ILoggerProvider loggerProvider) { this.AddProviderCallCount++; } From d6c8df464a91b2f7ce8ecd1628153f44e1c270b3 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:13:47 -0700 Subject: [PATCH 4/8] Address PR review: prevent double-wrap and add cycle detection - Add GetUnwrappedLoggerFactory() to prevent double-wrapping replay-safe loggers when wrapper contexts delegate to inner.ReplaySafeLoggerFactory - Add max-depth guard against infinite loops from misconfigured wrappers - Replace Moq with hand-rolled TrackingLoggerProvider in tests - Add regression tests for double-wrap and cycle detection - Remove unused using directive and unnecessary override in sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ReplaySafeLoggerFactorySample/Program.cs | 16 +- src/Abstractions/TaskOrchestrationContext.cs | 34 +++- ...tionContextReplaySafeLoggerFactoryTests.cs | 167 +++++++++++++++++- 3 files changed, 199 insertions(+), 18 deletions(-) diff --git a/samples/ReplaySafeLoggerFactorySample/Program.cs b/samples/ReplaySafeLoggerFactorySample/Program.cs index 3824f1b72..09c8ecdb0 100644 --- a/samples/ReplaySafeLoggerFactorySample/Program.cs +++ b/samples/ReplaySafeLoggerFactorySample/Program.cs @@ -17,9 +17,9 @@ namespace ReplaySafeLoggerFactorySample; -internal static class Program +static class Program { - private static async Task Main(string[] args) + static async Task Main(string[] args) { HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); @@ -72,7 +72,7 @@ private static async Task Main(string[] args) } } - private static void ConfigureDurableTask( + static void ConfigureDurableTask( HostApplicationBuilder builder, bool useScheduler, string? schedulerConnectionString) @@ -108,7 +108,7 @@ private static void ConfigureDurableTask( } [DurableTask(nameof(ReplaySafeLoggingOrchestration))] -internal sealed class ReplaySafeLoggingOrchestration : TaskOrchestrator +sealed class ReplaySafeLoggingOrchestration : TaskOrchestrator { public override async Task RunAsync(TaskOrchestrationContext context, string input) { @@ -125,7 +125,7 @@ public override async Task RunAsync(TaskOrchestrationContext context, st } [DurableTask(nameof(SayHelloActivity))] -internal sealed class SayHelloActivity : TaskActivity +sealed class SayHelloActivity : TaskActivity { readonly ILogger logger; @@ -142,7 +142,7 @@ public override Task RunAsync(TaskActivityContext context, string input) } } -internal sealed class LoggingTaskOrchestrationContext : TaskOrchestrationContext +sealed class LoggingTaskOrchestrationContext : TaskOrchestrationContext { readonly TaskOrchestrationContext innerContext; @@ -151,6 +151,8 @@ public LoggingTaskOrchestrationContext(TaskOrchestrationContext innerContext) this.innerContext = innerContext ?? throw new ArgumentNullException(nameof(innerContext)); } + // Only abstract members need explicit forwarding here. Virtual helpers such as + // ReplaySafeLoggerFactory and the convenience overloads continue to work through these overrides. public override TaskName Name => this.innerContext.Name; public override string InstanceId => this.innerContext.InstanceId; @@ -165,8 +167,6 @@ public LoggingTaskOrchestrationContext(TaskOrchestrationContext innerContext) public override IReadOnlyDictionary Properties => this.innerContext.Properties; - public override TaskOrchestrationEntityFeature Entities => this.innerContext.Entities; - protected override ILoggerFactory LoggerFactory => this.innerContext.ReplaySafeLoggerFactory; public ILogger CreateLogger() diff --git a/src/Abstractions/TaskOrchestrationContext.cs b/src/Abstractions/TaskOrchestrationContext.cs index 4bc404ac0..83f668d05 100644 --- a/src/Abstractions/TaskOrchestrationContext.cs +++ b/src/Abstractions/TaskOrchestrationContext.cs @@ -433,17 +433,17 @@ public virtual Task CallSubOrchestratorAsync( /// The logger's category name. /// An instance of that is replay-safe. public virtual ILogger CreateReplaySafeLogger(string categoryName) - => new ReplaySafeLogger(this, this.LoggerFactory.CreateLogger(categoryName)); + => new ReplaySafeLogger(this, this.GetUnwrappedLoggerFactory().CreateLogger(categoryName)); /// /// The type to derive the category name from. public virtual ILogger CreateReplaySafeLogger(Type type) - => new ReplaySafeLogger(this, this.LoggerFactory.CreateLogger(type)); + => new ReplaySafeLogger(this, this.GetUnwrappedLoggerFactory().CreateLogger(type)); /// /// The type to derive category name from. public virtual ILogger CreateReplaySafeLogger() - => new ReplaySafeLogger(this, this.LoggerFactory.CreateLogger()); + => new ReplaySafeLogger(this, this.GetUnwrappedLoggerFactory().CreateLogger()); /// /// Checks if the current orchestration version is greater than the specified version. @@ -466,6 +466,30 @@ public virtual int CompareVersionTo(string version) return TaskOrchestrationVersioningUtils.CompareVersions(this.Version, version); } + ILoggerFactory GetUnwrappedLoggerFactory() + { + ILoggerFactory loggerFactory = this.LoggerFactory; + int depth = 0; + + // When a wrapper context delegates LoggerFactory to inner.ReplaySafeLoggerFactory, + // the returned factory is already a ReplaySafeLoggerFactoryImpl. Unwrap it to avoid + // double-wrapping loggers with redundant replay-safe checks. + while (loggerFactory is ReplaySafeLoggerFactoryImpl replaySafeLoggerFactory) + { + if (++depth > 10) + { + throw new InvalidOperationException( + "Cycle detected while unwrapping ReplaySafeLoggerFactory. " + + "Ensure the wrapper's LoggerFactory property delegates to the inner context's " + + "ReplaySafeLoggerFactory (e.g., 'inner.ReplaySafeLoggerFactory'), not 'this.ReplaySafeLoggerFactory'."); + } + + loggerFactory = replaySafeLoggerFactory.UnderlyingLoggerFactory; + } + + return loggerFactory; + } + sealed class ReplaySafeLogger : ILogger { readonly TaskOrchestrationContext context; @@ -506,8 +530,10 @@ internal ReplaySafeLoggerFactoryImpl(TaskOrchestrationContext context) this.context = context ?? throw new ArgumentNullException(nameof(context)); } + internal ILoggerFactory UnderlyingLoggerFactory => this.context.LoggerFactory; + public ILogger CreateLogger(string categoryName) - => this.context.CreateReplaySafeLogger(categoryName); + => new ReplaySafeLogger(this.context, this.context.GetUnwrappedLoggerFactory().CreateLogger(categoryName)); public void AddProvider(ILoggerProvider provider) => throw new NotSupportedException( diff --git a/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs b/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs index 61f9f1084..bdb7c0ea6 100644 --- a/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs +++ b/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.DurableTask.Entities; using Microsoft.Extensions.Logging; namespace Microsoft.DurableTask.Tests; @@ -65,10 +64,10 @@ public void ReplaySafeLoggerFactory_AddProvider_ThrowsWithoutMutatingUnderlyingF TrackingLoggerProvider provider = new(); TrackingLoggerFactory loggerFactory = new(provider); TestTaskOrchestrationContext context = new(loggerFactory, isReplaying: false); - Mock additionalProvider = new(); + TrackingLoggerProvider additionalProvider = new(); // Act - Action act = () => context.ReplaySafeLoggerFactory.AddProvider(additionalProvider.Object); + Action act = () => context.ReplaySafeLoggerFactory.AddProvider(additionalProvider); // Assert act.Should().Throw() @@ -76,6 +75,42 @@ public void ReplaySafeLoggerFactory_AddProvider_ThrowsWithoutMutatingUnderlyingF loggerFactory.AddProviderCallCount.Should().Be(0); } + [Fact] + public void ReplaySafeLoggerFactory_CreateLogger_FromWrappedContext_ChecksReplayOnce() + { + // Arrange + TrackingLoggerProvider provider = new(); + TrackingLoggerFactory loggerFactory = new(provider); + TestTaskOrchestrationContext innerContext = new(loggerFactory, isReplaying: false); + WrappingTaskOrchestrationContext wrappedContext = new(innerContext); + ILogger logger = wrappedContext.ReplaySafeLoggerFactory.CreateLogger("ReplaySafe"); + + // Act + logger.LogInformation("This log should be written."); + + // Assert + innerContext.IsReplayingAccessCount.Should().Be(1); + provider.Entries.Should().ContainSingle(entry => + entry.CategoryName == "ReplaySafe" && + entry.Message.Contains("This log should be written.", StringComparison.Ordinal)); + } + + [Fact] + public void ReplaySafeLoggerFactory_CreateLogger_ThrowsOnCyclicLoggerFactory() + { + // Arrange + TrackingLoggerProvider provider = new(); + TrackingLoggerFactory loggerFactory = new(provider); + SelfReferencingContext cyclicContext = new(loggerFactory); + + // Act + Action act = () => cyclicContext.ReplaySafeLoggerFactory.CreateLogger("Test"); + + // Assert + act.Should().Throw() + .WithMessage("*Cycle detected*"); + } + [Fact] public void ReplaySafeLoggerFactory_Dispose_DoesNotDisposeUnderlyingFactory() { @@ -110,11 +145,18 @@ public TestTaskOrchestrationContext(ILoggerFactory loggerFactory, bool isReplayi public override DateTime CurrentUtcDateTime => DateTime.UnixEpoch; - public override bool IsReplaying => this.isReplaying; + public int IsReplayingAccessCount { get; private set; } - public override IReadOnlyDictionary Properties => new Dictionary(); + public override bool IsReplaying + { + get + { + this.IsReplayingAccessCount++; + return this.isReplaying; + } + } - public override TaskOrchestrationEntityFeature Entities => throw new NotSupportedException(); + public override IReadOnlyDictionary Properties => new Dictionary(); protected override ILoggerFactory LoggerFactory => this.loggerFactory; @@ -150,6 +192,119 @@ public override Guid NewGuid() => throw new NotImplementedException(); } + sealed class WrappingTaskOrchestrationContext : TaskOrchestrationContext + { + readonly TaskOrchestrationContext innerContext; + + public WrappingTaskOrchestrationContext(TaskOrchestrationContext innerContext) + { + this.innerContext = innerContext ?? throw new ArgumentNullException(nameof(innerContext)); + } + + public override TaskName Name => this.innerContext.Name; + + public override string InstanceId => this.innerContext.InstanceId; + + public override ParentOrchestrationInstance? Parent => this.innerContext.Parent; + + public override DateTime CurrentUtcDateTime => this.innerContext.CurrentUtcDateTime; + + public override bool IsReplaying => this.innerContext.IsReplaying; + + public override string Version => this.innerContext.Version; + + public override IReadOnlyDictionary Properties => this.innerContext.Properties; + + protected override ILoggerFactory LoggerFactory => this.innerContext.ReplaySafeLoggerFactory; + + public override T GetInput() + where T : default + => this.innerContext.GetInput()!; + + public override Task CallActivityAsync(TaskName name, object? input = null, TaskOptions? options = null) + => this.innerContext.CallActivityAsync(name, input, options); + + public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) + => this.innerContext.CreateTimer(fireAt, cancellationToken); + + public override Task WaitForExternalEvent(string eventName, CancellationToken cancellationToken = default) + => this.innerContext.WaitForExternalEvent(eventName, cancellationToken); + + public override void SendEvent(string instanceId, string eventName, object payload) + => this.innerContext.SendEvent(instanceId, eventName, payload); + + public override void SetCustomStatus(object? customStatus) + => this.innerContext.SetCustomStatus(customStatus); + + public override Task CallSubOrchestratorAsync( + TaskName orchestratorName, + object? input = null, + TaskOptions? options = null) + => this.innerContext.CallSubOrchestratorAsync(orchestratorName, input, options); + + public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) + => this.innerContext.ContinueAsNew(newInput, preserveUnprocessedEvents); + + public override Guid NewGuid() + => this.innerContext.NewGuid(); + } + + sealed class SelfReferencingContext : TaskOrchestrationContext + { + readonly ILoggerFactory loggerFactory; + + public SelfReferencingContext(ILoggerFactory loggerFactory) + { + this.loggerFactory = loggerFactory; + } + + public override TaskName Name => default; + + public override string InstanceId => "cyclic-instance"; + + public override ParentOrchestrationInstance? Parent => null; + + public override DateTime CurrentUtcDateTime => DateTime.UnixEpoch; + + public override bool IsReplaying => false; + + public override IReadOnlyDictionary Properties => new Dictionary(); + + // Bug: points at self instead of an inner context — should cause cycle detection. + protected override ILoggerFactory LoggerFactory => this.ReplaySafeLoggerFactory; + + public override T GetInput() + where T : default + => default!; + + public override Task CallActivityAsync(TaskName name, object? input = null, TaskOptions? options = null) + => throw new NotImplementedException(); + + public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public override Task WaitForExternalEvent(string eventName, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public override void SendEvent(string instanceId, string eventName, object payload) + => throw new NotImplementedException(); + + public override void SetCustomStatus(object? customStatus) + => throw new NotImplementedException(); + + public override Task CallSubOrchestratorAsync( + TaskName orchestratorName, + object? input = null, + TaskOptions? options = null) + => throw new NotImplementedException(); + + public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) + => throw new NotImplementedException(); + + public override Guid NewGuid() + => throw new NotImplementedException(); + } + sealed class TrackingLoggerFactory : ILoggerFactory { readonly TrackingLoggerProvider provider; From b118da98838947fb3396121584e084acf365dd4e Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:19:54 -0700 Subject: [PATCH 5/8] Rename pattern variable to avoid shadowing field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/TaskOrchestrationContext.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Abstractions/TaskOrchestrationContext.cs b/src/Abstractions/TaskOrchestrationContext.cs index 83f668d05..fbd2741da 100644 --- a/src/Abstractions/TaskOrchestrationContext.cs +++ b/src/Abstractions/TaskOrchestrationContext.cs @@ -474,7 +474,7 @@ ILoggerFactory GetUnwrappedLoggerFactory() // When a wrapper context delegates LoggerFactory to inner.ReplaySafeLoggerFactory, // the returned factory is already a ReplaySafeLoggerFactoryImpl. Unwrap it to avoid // double-wrapping loggers with redundant replay-safe checks. - while (loggerFactory is ReplaySafeLoggerFactoryImpl replaySafeLoggerFactory) + while (loggerFactory is ReplaySafeLoggerFactoryImpl wrappedFactory) { if (++depth > 10) { @@ -484,7 +484,7 @@ ILoggerFactory GetUnwrappedLoggerFactory() "ReplaySafeLoggerFactory (e.g., 'inner.ReplaySafeLoggerFactory'), not 'this.ReplaySafeLoggerFactory'."); } - loggerFactory = replaySafeLoggerFactory.UnderlyingLoggerFactory; + loggerFactory = wrappedFactory.UnderlyingLoggerFactory; } return loggerFactory; From e5f9190284e2d39c6c214a5c42c94b0ddb86df0b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:22:00 -0700 Subject: [PATCH 6/8] Fix review feedback: sln cleanup, GetInput signatures, error message clarity (#671) --- Microsoft.DurableTask.sln | 25 ------------------- .../ReplaySafeLoggerFactorySample/Program.cs | 1 - src/Abstractions/TaskOrchestrationContext.cs | 2 +- ...tionContextReplaySafeLoggerFactoryTests.cs | 5 +--- 4 files changed, 2 insertions(+), 31 deletions(-) diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 795992c89..9336d0846 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -113,39 +113,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DistributedTracingSample", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReplaySafeLoggerFactorySample", "samples\ReplaySafeLoggerFactorySample\ReplaySafeLoggerFactorySample.csproj", "{8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Grpc", "Grpc", "{51A52603-541D-DE3F-2825-C80F9EE6C532}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{D4587EC0-1B16-8420-7502-A967139249D4}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Grpc", "Grpc", "{3B8F957E-7773-4C0C-ACD7-91A1591D9312}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{53193780-CD18-2643-6953-C26F59EAEDF5}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x64.ActiveCfg = Debug|Any CPU - {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x64.Build.0 = Debug|Any CPU - {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x86.ActiveCfg = Debug|Any CPU - {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Debug|x86.Build.0 = Debug|Any CPU {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|Any CPU.Build.0 = Release|Any CPU - {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x64.ActiveCfg = Release|Any CPU - {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x64.Build.0 = Release|Any CPU - {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x86.ActiveCfg = Release|Any CPU - {B12489CB-B7E5-497B-8F0C-F87F678947C3}.Release|x86.Build.0 = Release|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x64.ActiveCfg = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x64.Build.0 = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x86.ActiveCfg = Debug|Any CPU {B0EB48BE-E4F7-4F50-B8BD-5C6172A7A584}.Debug|x86.Build.0 = Debug|Any CPU @@ -751,10 +730,6 @@ Global {05C9EBA6-7221-D458-47D6-DA457C2F893B} = {E5637F81-2FB9-4CD7-900D-455363B142A7} {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} - {51A52603-541D-DE3F-2825-C80F9EE6C532} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} - {D4587EC0-1B16-8420-7502-A967139249D4} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} - {3B8F957E-7773-4C0C-ACD7-91A1591D9312} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} - {53193780-CD18-2643-6953-C26F59EAEDF5} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/samples/ReplaySafeLoggerFactorySample/Program.cs b/samples/ReplaySafeLoggerFactorySample/Program.cs index 09c8ecdb0..6ee494b44 100644 --- a/samples/ReplaySafeLoggerFactorySample/Program.cs +++ b/samples/ReplaySafeLoggerFactorySample/Program.cs @@ -187,7 +187,6 @@ public async Task CallActivityWithLoggingAsync( } public override T GetInput() - where T : default => this.innerContext.GetInput()!; public override Task CallActivityAsync( diff --git a/src/Abstractions/TaskOrchestrationContext.cs b/src/Abstractions/TaskOrchestrationContext.cs index fbd2741da..49a1e6b09 100644 --- a/src/Abstractions/TaskOrchestrationContext.cs +++ b/src/Abstractions/TaskOrchestrationContext.cs @@ -479,7 +479,7 @@ ILoggerFactory GetUnwrappedLoggerFactory() if (++depth > 10) { throw new InvalidOperationException( - "Cycle detected while unwrapping ReplaySafeLoggerFactory. " + + "Maximum unwrap depth exceeded while resolving the underlying ILoggerFactory. " + "Ensure the wrapper's LoggerFactory property delegates to the inner context's " + "ReplaySafeLoggerFactory (e.g., 'inner.ReplaySafeLoggerFactory'), not 'this.ReplaySafeLoggerFactory'."); } diff --git a/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs b/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs index bdb7c0ea6..449840c10 100644 --- a/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs +++ b/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs @@ -108,7 +108,7 @@ public void ReplaySafeLoggerFactory_CreateLogger_ThrowsOnCyclicLoggerFactory() // Assert act.Should().Throw() - .WithMessage("*Cycle detected*"); + .WithMessage("*Maximum unwrap depth exceeded*"); } [Fact] @@ -161,7 +161,6 @@ public override bool IsReplaying protected override ILoggerFactory LoggerFactory => this.loggerFactory; public override T GetInput() - where T : default => default!; public override Task CallActivityAsync(TaskName name, object? input = null, TaskOptions? options = null) @@ -218,7 +217,6 @@ public WrappingTaskOrchestrationContext(TaskOrchestrationContext innerContext) protected override ILoggerFactory LoggerFactory => this.innerContext.ReplaySafeLoggerFactory; public override T GetInput() - where T : default => this.innerContext.GetInput()!; public override Task CallActivityAsync(TaskName name, object? input = null, TaskOptions? options = null) @@ -274,7 +272,6 @@ public SelfReferencingContext(ILoggerFactory loggerFactory) protected override ILoggerFactory LoggerFactory => this.ReplaySafeLoggerFactory; public override T GetInput() - where T : default => default!; public override Task CallActivityAsync(TaskName name, object? input = null, TaskOptions? options = null) From db58f18a2e62eee32eeba2cca3458a36b257a255 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:53:59 -0700 Subject: [PATCH 7/8] Increase integration test timeout from 10s to 30s The 10-second default timeout was too tight for CI environments with resource constraints, causing flaky failures in tests like SubOrchestration, SetCustomStatus, and OrchestrationVersioning due to OperationCanceledException. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/Grpc.IntegrationTests/IntegrationTestBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Grpc.IntegrationTests/IntegrationTestBase.cs b/test/Grpc.IntegrationTests/IntegrationTestBase.cs index ded827769..93b695dd4 100644 --- a/test/Grpc.IntegrationTests/IntegrationTestBase.cs +++ b/test/Grpc.IntegrationTests/IntegrationTestBase.cs @@ -18,7 +18,7 @@ namespace Microsoft.DurableTask.Grpc.Tests; public class IntegrationTestBase : IClassFixture, IDisposable { readonly CancellationTokenSource testTimeoutSource - = new(Debugger.IsAttached ? TimeSpan.FromMinutes(5) : TimeSpan.FromSeconds(10)); + = new(Debugger.IsAttached ? TimeSpan.FromMinutes(5) : TimeSpan.FromSeconds(30)); readonly TestLogProvider logProvider; From 110ebd4730c34bc53fb0f1207be0af57574fdb3b Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:56:06 -0700 Subject: [PATCH 8/8] Remove unused field and fix parameter naming in tests - Remove unused loggerFactory field from SelfReferencingContext - Rename AddProvider parameter to match ILoggerFactory interface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...rchestrationContextReplaySafeLoggerFactoryTests.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs b/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs index 449840c10..70d91857e 100644 --- a/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs +++ b/test/Abstractions.Tests/TaskOrchestrationContextReplaySafeLoggerFactoryTests.cs @@ -99,9 +99,7 @@ public void ReplaySafeLoggerFactory_CreateLogger_FromWrappedContext_ChecksReplay public void ReplaySafeLoggerFactory_CreateLogger_ThrowsOnCyclicLoggerFactory() { // Arrange - TrackingLoggerProvider provider = new(); - TrackingLoggerFactory loggerFactory = new(provider); - SelfReferencingContext cyclicContext = new(loggerFactory); + SelfReferencingContext cyclicContext = new(); // Act Action act = () => cyclicContext.ReplaySafeLoggerFactory.CreateLogger("Test"); @@ -249,11 +247,8 @@ public override Guid NewGuid() sealed class SelfReferencingContext : TaskOrchestrationContext { - readonly ILoggerFactory loggerFactory; - - public SelfReferencingContext(ILoggerFactory loggerFactory) + public SelfReferencingContext() { - this.loggerFactory = loggerFactory; } public override TaskName Name => default; @@ -315,7 +310,7 @@ public TrackingLoggerFactory(TrackingLoggerProvider provider) public int DisposeCallCount { get; private set; } - public void AddProvider(ILoggerProvider loggerProvider) + public void AddProvider(ILoggerProvider provider) { this.AddProviderCallCount++; }