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
14 changes: 14 additions & 0 deletions Microsoft.DurableTask.sln
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LargePayloadConsoleApp", "s
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureBlobPayloads", "src\Extensions\AzureBlobPayloads\AzureBlobPayloads.csproj", "{FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InProcessTestHost", "src\InProcessTestHost\InProcessTestHost.csproj", "{5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InProcessTestHost.Tests", "test\InProcessTestHost.Tests\InProcessTestHost.Tests.csproj", "{B894780C-338F-475E-8E84-56AFA8197A06}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -251,6 +255,14 @@ Global
{D2779F32-A548-44F8-B60A-6AC018966C79}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2779F32-A548-44F8-B60A-6AC018966C79}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D2779F32-A548-44F8-B60A-6AC018966C79}.Release|Any CPU.Build.0 = Release|Any CPU
{5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Release|Any CPU.Build.0 = Release|Any CPU
{B894780C-338F-475E-8E84-56AFA8197A06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B894780C-338F-475E-8E84-56AFA8197A06}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B894780C-338F-475E-8E84-56AFA8197A06}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B894780C-338F-475E-8E84-56AFA8197A06}.Release|Any CPU.Build.0 = Release|Any CPU
{6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand Down Expand Up @@ -305,6 +317,8 @@ Global
{A89B766C-987F-4C9F-8937-D0AB9FE640C8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{100348B5-4D97-4A3F-B777-AB14F276F8FE} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{D2779F32-A548-44F8-B60A-6AC018966C79} = {E5637F81-2FB9-4CD7-900D-455363B142A7}
{5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
{B894780C-338F-475E-8E84-56AFA8197A06} = {E5637F81-2FB9-4CD7-900D-455363B142A7}
{6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{FE1DA748-D6DB-E168-BC42-6DBBCEAF229C} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
EndGlobalSection
Expand Down
168 changes: 168 additions & 0 deletions src/InProcessTestHost/DurableTaskTestHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using DurableTask.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Testing.Sidecar;
using Microsoft.DurableTask.Testing.Sidecar.Grpc;
using Microsoft.DurableTask.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Microsoft.DurableTask.Testing;

/// <summary>
/// In-process test host for testing class-based durable task orchestrations and activities
/// without requiring any external backend (Azure Storage, SQL, etc).
/// </summary>
public sealed class DurableTaskTestHost : IAsyncDisposable
{
readonly IWebHost sidecarHost;
readonly IHost workerHost;
readonly GrpcChannel grpcChannel;

/// <summary>
/// Initializes a new instance of the <see cref="DurableTaskTestHost"/> class.
/// </summary>
/// <param name="sidecarHost">The gRPC sidecar host.</param>
/// <param name="workerHost">The worker host.</param>
/// <param name="grpcChannel">The gRPC channel.</param>
/// <param name="client">The durable task client.</param>
public DurableTaskTestHost(IWebHost sidecarHost, IHost workerHost, GrpcChannel grpcChannel, DurableTaskClient client)
{
this.sidecarHost = sidecarHost;
this.workerHost = workerHost;
this.grpcChannel = grpcChannel;
this.Client = client;
}

/// <summary>
/// Gets the durable task client for scheduling and managing orchestrations.
/// </summary>
public DurableTaskClient Client { get; }

/// <summary>
/// Starts a new in-process test host with the specified orchestrators and activities.
/// </summary>
/// <param name="registry">Action to configure the task registry by adding orchestrators and activities.</param>
/// <param name="options">Optional configuration options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A running test host ready to execute orchestrations.</returns>
public static async Task<DurableTaskTestHost> StartAsync(
Action<DurableTaskRegistry> registry,
DurableTaskTestHostOptions? options = null,
CancellationToken cancellationToken = default)
{
options ??= new DurableTaskTestHostOptions();

// Create in-memory orchestration service
var orchestrationService = new InMemoryOrchestrationService(options.LoggerFactory);

// Start gRPC sidecar server in-process
string address = options.Port.HasValue
? $"http://localhost:{options.Port.Value}"
: $"http://localhost:{Random.Shared.Next(30000, 40000)}";

var sidecarHost = new WebHostBuilder()
.UseKestrel(kestrelOptions =>
{
// Configure for HTTP/2 (required for gRPC)
kestrelOptions.ConfigureEndpointDefaults(listenOptions =>
listenOptions.Protocols = HttpProtocols.Http2);
})
.UseUrls(address)
.ConfigureServices(services =>
{
services.AddGrpc();
services.AddSingleton<IOrchestrationService>(orchestrationService);
services.AddSingleton<IOrchestrationServiceClient>(orchestrationService);
services.AddSingleton<TaskHubGrpcServer>();
})
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGrpcService<TaskHubGrpcServer>();
});
})
.Build();

sidecarHost.Start();
var grpcChannel = GrpcChannel.ForAddress(address);

// Create worker host with user's orchestrators and activities
var workerHost = Host.CreateDefaultBuilder()
.ConfigureLogging(logging =>
{
logging.ClearProviders();
if (options.LoggerFactory != null)
{
logging.Services.AddSingleton(options.LoggerFactory);
}
})
.ConfigureServices(services =>
{
// Register worker that connects to our in-process sidecar
services.AddDurableTaskWorker(builder =>
{
builder.UseGrpc(grpcChannel);
builder.AddTasks(registry);
});

// Register client that connects to the same sidecar
services.AddDurableTaskClient(builder =>
{
builder.UseGrpc(grpcChannel);
builder.RegisterDirectly();
});
})
.Build();

await workerHost.StartAsync(cancellationToken);

// Get the client from the worker host
var client = workerHost.Services.GetRequiredService<DurableTaskClient>();

return new DurableTaskTestHost(sidecarHost, workerHost, grpcChannel, client);
}

/// <summary>
/// Clean up all resources.
/// </summary>
/// <returns>A task representing the asynchronous dispose operation.</returns>
public async ValueTask DisposeAsync()
{
await this.workerHost.StopAsync();
this.workerHost.Dispose();

await this.grpcChannel.ShutdownAsync();
this.grpcChannel.Dispose();

await this.sidecarHost.StopAsync();
this.sidecarHost.Dispose();
}
}

/// <summary>
/// Configuration options for <see cref="DurableTaskTestHost"/>.
/// </summary>
public class DurableTaskTestHostOptions
{
/// <summary>
/// Gets or sets the specific port to use for the gRPC sidecar.
/// If not set, a random port between 30000-40000 will be used.
/// </summary>
public int? Port { get; set; }

/// <summary>
/// Gets or sets an optional logger factory for capturing logs during tests.
/// Null by default.
/// </summary>
public ILoggerFactory? LoggerFactory { get; set; }
}
27 changes: 27 additions & 0 deletions src/InProcessTestHost/InProcessTestHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know some of our packages support netstandard2.0 and net6. Do we need to do that here as well or are we OK with just net6?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just tried, we are using Grpc.AspNetCore.Server in this test pkg which doesn't support netstandard2.0. I guess we are fine? If there are special customer asking for that then I can move back to add the TFM. Now it's just preview pkg

<RootNamespace>Microsoft.DurableTask.Testing</RootNamespace>
<AssemblyName>Microsoft.DurableTask.InProcessTestHost</AssemblyName>
<PackageId>Microsoft.DurableTask.InProcessTestHost</PackageId>
Comment thread
nytian marked this conversation as resolved.
<Version>0.1.0-preview.1</Version>

<!-- Suppress CA1848: Use LoggerMessage delegates for high-performance logging scenarios -->
<NoWarn>$(NoWarn);CA1848</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Azure.DurableTask.Core" />
<PackageReference Include="Grpc.AspNetCore.Server" />
<PackageReference Include="Grpc.Net.Client" />
<PackageReference Include="Google.Protobuf" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../Client/Grpc/Client.Grpc.csproj" />
<ProjectReference Include="../Worker/Grpc/Worker.Grpc.csproj" />
<ProjectReference Include="../Grpc/Grpc.csproj" />
</ItemGroup>

</Project>
43 changes: 43 additions & 0 deletions src/InProcessTestHost/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# DurableTaskTestHost - Testing Durable Orchestrations In-Process

`DurableTaskTestHost` is a simple API for testing your durable task orchestrations and activities **in-process** without requiring any external backend.

Supports both **class-based** and **function-based** syntax.

## Quick Start

1. Configure options
```csharp
var options = new DurableTaskTestHostOptions
{
Port = 31000, // Optional: specific port (random by default)
LoggerFactory = myLoggerFactory // Optional: pass logger factory for logging
};

```

2. Register test orchestrations and activities.

```csharp
await using var testHost = await DurableTaskTestHost.StartAsync(registry =>
{
// Class-based
registry.AddOrchestrator<MyOrchestrator>();
registry.AddActivity<MyActivity>();

// Function-based
registry.AddOrchestratorFunc("MyFunc", (ctx, input) => Task.FromResult("done"));
registry.AddActivityFunc("MyActivity", (ctx, input) => Task.FromResult("result"));
});

```

3. Test
```csharp
string instanceId = await testHost.Client.ScheduleNewOrchestrationInstanceAsync("MyOrchestrator");
var result = await testHost.Client.WaitForInstanceCompletionAsync(instanceId);
```
.
## More Samples

See [BasicOrchestrationTests.cs](../../test/InProcessTestHost.Tests/BasicOrchestrationTests.cs) for complete samples showing both class-syntax and function-syntax orchestrations.
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
// Copyright (c) Microsoft Corporation.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Microsoft.DurableTask.Sidecar;
namespace Microsoft.DurableTask.Testing.Sidecar;

/// <summary>
/// Helper class for fetching TaskHub events.
/// </summary>
class AsyncManualResetEvent
Comment thread
nytian marked this conversation as resolved.
{
readonly object mutex = new();
TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);

/// <summary>
/// Initializes a new instance of the <see cref="AsyncManualResetEvent"/> class.
/// </summary>
/// <param name="isSignaled">Whether the event should start in the signaled state.</param>
public AsyncManualResetEvent(bool isSignaled)
{
if (isSignaled)
Expand All @@ -16,6 +23,17 @@ public AsyncManualResetEvent(bool isSignaled)
}
}

/// <summary>
/// Gets a value indicating whether the event is in the signaled state.
/// </summary>
public bool IsSignaled => this.tcs.Task.IsCompleted;

/// <summary>
/// Waits for the event to be signaled with a timeout.
/// </summary>
/// <param name="timeout">The timeout duration.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>True if the event was signaled, false if the timeout occurred.</returns>
public async Task<bool> WaitAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
Task delayTask = Task.Delay(timeout, cancellationToken);
Expand All @@ -29,11 +47,10 @@ public async Task<bool> WaitAsync(TimeSpan timeout, CancellationToken cancellati
return winner == waitTask;
}

public bool IsSignaled => this.tcs.Task.IsCompleted;

/// <summary>
/// Puts the event in the signaled state, unblocking any waiting threads.
/// </summary>
/// <returns>True if result is set.</returns>
public bool Set()
{
lock (this.mutex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@
using DurableTask.Core.Command;
using DurableTask.Core.Tracing;

namespace Microsoft.DurableTask.Sidecar.Dispatcher;
namespace Microsoft.DurableTask.Testing.Sidecar.Dispatcher;

/// <summary>
/// Action for creating sub-orchestration.
/// </summary>
public class GrpcCreateSubOrchestrationAction : CreateSubOrchestrationAction
{
/// <summary>
/// Gets or sets distributed parent trace context.
/// </summary>
public DistributedTraceContext? ParentTraceContext { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using DurableTask.Core;

namespace Microsoft.DurableTask.Testing.Sidecar.Dispatcher;

/// <summary>
/// Grpc orchestration execution result.
/// </summary>
public class GrpcOrchestratorExecutionResult : OrchestratorExecutionResult
{
/// <summary>
/// Gets or sets the orcehstration activity spanId.
/// </summary>
public string? OrchestrationActivitySpanId { get; set; }

/// <summary>
/// Gets or sets the orchestration activity start time.
/// </summary>
public DateTimeOffset? OrchestrationActivityStartTime { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using DurableTask.Core.Command;
using DurableTask.Core.Tracing;

namespace Microsoft.DurableTask.Testing.Sidecar.Dispatcher;

/// <summary>
/// gRPC-specific implementation of ScheduleTaskOrchestratorAction that includes distributed tracing context.
/// </summary>
public class GrpcScheduleTaskOrchestratorAction : ScheduleTaskOrchestratorAction
{
/// <summary>
/// Gets or sets the parent trace context for distributed tracing.
/// </summary>
public DistributedTraceContext? ParentTraceContext { get; set; }
}
Loading
Loading