Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
15 changes: 15 additions & 0 deletions Microsoft.DurableTask.sln
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ 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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -660,6 +662,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
Expand Down Expand Up @@ -715,6 +729,7 @@ 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}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71}
Expand Down
221 changes: 221 additions & 0 deletions samples/ReplaySafeLoggerFactorySample/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// 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;

static class Program
{
static async Task Main(string[] args)
{
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

string? schedulerConnectionString = builder.Configuration.GetValue<string>("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<DurableTaskClient>();

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<string>()}");
Console.WriteLine();
Console.WriteLine(
"The wrapper delegates LoggerFactory to inner.ReplaySafeLoggerFactory, " +
"so wrapper-level logging stays replay-safe.");
}
finally
{
await host.StopAsync();
}
}

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<ReplaySafeLoggingOrchestration>();
tasks.AddActivity<SayHelloActivity>();
});

workerBuilder.UseDurableTaskScheduler(schedulerConnectionString!);
});
}
else
{
builder.Services.AddDurableTaskClient().UseGrpc();

builder.Services.AddDurableTaskWorker()
.AddTasks(tasks =>
{
tasks.AddOrchestrator<ReplaySafeLoggingOrchestration>();
tasks.AddActivity<SayHelloActivity>();
})
.UseGrpc();
}
}
}

[DurableTask(nameof(ReplaySafeLoggingOrchestration))]
sealed class ReplaySafeLoggingOrchestration : TaskOrchestrator<string, string>
{
public override async Task<string> RunAsync(TaskOrchestrationContext context, string input)
{
LoggingTaskOrchestrationContext wrappedContext = new(context);
ILogger logger = wrappedContext.CreateLogger<ReplaySafeLoggingOrchestration>();

logger.LogInformation("Wrapping orchestration context for instance {InstanceId}.", wrappedContext.InstanceId);

string greeting = await wrappedContext.CallActivityWithLoggingAsync<string>(nameof(SayHelloActivity), input);

logger.LogInformation("Returning activity result for {InstanceId}.", wrappedContext.InstanceId);
return greeting;
}
}

[DurableTask(nameof(SayHelloActivity))]
sealed class SayHelloActivity : TaskActivity<string, string>
{
readonly ILogger<SayHelloActivity> logger;

public SayHelloActivity(ILoggerFactory loggerFactory)
{
this.logger = loggerFactory.CreateLogger<SayHelloActivity>();
}

public override Task<string> 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.");
}
}

sealed class LoggingTaskOrchestrationContext : TaskOrchestrationContext
{
readonly TaskOrchestrationContext innerContext;

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;

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<string, object?> Properties => this.innerContext.Properties;

protected override ILoggerFactory LoggerFactory => this.innerContext.ReplaySafeLoggerFactory;

public ILogger CreateLogger<T>()
=> this.CreateReplaySafeLogger<T>();

public async Task<TResult> CallActivityWithLoggingAsync<TResult>(
TaskName name,
object? input = null,
TaskOptions? options = null)
{
ILogger logger = this.CreateReplaySafeLogger<LoggingTaskOrchestrationContext>();
logger.LogInformation("Calling activity {ActivityName} for instance {InstanceId}.", name.Name, this.InstanceId);

TResult result = await this.CallActivityAsync<TResult>(name, input, options);

logger.LogInformation("Activity {ActivityName} completed for instance {InstanceId}.", name.Name, this.InstanceId);
return result;
}

public override T GetInput<T>()
=> this.innerContext.GetInput<T>()!;

public override Task<TResult> CallActivityAsync<TResult>(
TaskName name,
object? input = null,
TaskOptions? options = null)
=> this.innerContext.CallActivityAsync<TResult>(name, input, options);

public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken)
=> this.innerContext.CreateTimer(fireAt, cancellationToken);

public override Task<T> WaitForExternalEvent<T>(string eventName, CancellationToken cancellationToken = default)
=> this.innerContext.WaitForExternalEvent<T>(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<TResult> CallSubOrchestratorAsync<TResult>(
TaskName orchestratorName,
object? input = null,
TaskOptions? options = null)
=> this.innerContext.CallSubOrchestratorAsync<TResult>(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();
}
78 changes: 78 additions & 0 deletions samples/ReplaySafeLoggerFactorySample/README.md
Original file line number Diff line number Diff line change
@@ -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<TResult> CallActivityWithLoggingAsync<TResult>(TaskName name, object? input = null)
{
ILogger logger = this.CreateReplaySafeLogger<LoggingTaskOrchestrationContext>();
logger.LogInformation("Calling activity {ActivityName}.", name.Name);
TResult result = await this.CallActivityAsync<TResult>(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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />

<!-- Real projects would use package references -->
<!--
<PackageReference Include="Microsoft.DurableTask.Client.Grpc" Version="1.5.0" />
<PackageReference Include="Microsoft.DurableTask.Worker.Grpc" Version="1.5.0" />
-->
</ItemGroup>

<ItemGroup>
<!-- Using p2p references so we can show latest changes in samples. -->
<ProjectReference Include="$(SrcRoot)Client/Grpc/Client.Grpc.csproj" />
<ProjectReference Include="$(SrcRoot)Client/AzureManaged/Client.AzureManaged.csproj" />
<ProjectReference Include="$(SrcRoot)Worker/Grpc/Worker.Grpc.csproj" />
<ProjectReference Include="$(SrcRoot)Worker/AzureManaged/Worker.AzureManaged.csproj" />
<ProjectReference Include="$(SrcRoot)Analyzers/Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

</Project>
Loading
Loading