diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 2aee6eb2..3a24983d 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -65,6 +65,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Grpc", "src\Grpc\Grpc.cspro EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks", "test\Benchmarks\Benchmarks.csproj", "{82C0CD7D-2764-421A-8256-7E2304D5A6E7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Preview", "samples\Preview\Preview.csproj", "{EA7F706E-9738-4DDB-9089-F17F927E1247}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -167,6 +169,10 @@ Global {82C0CD7D-2764-421A-8256-7E2304D5A6E7}.Debug|Any CPU.Build.0 = Debug|Any CPU {82C0CD7D-2764-421A-8256-7E2304D5A6E7}.Release|Any CPU.ActiveCfg = Release|Any CPU {82C0CD7D-2764-421A-8256-7E2304D5A6E7}.Release|Any CPU.Build.0 = Release|Any CPU + {EA7F706E-9738-4DDB-9089-F17F927E1247}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA7F706E-9738-4DDB-9089-F17F927E1247}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA7F706E-9738-4DDB-9089-F17F927E1247}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA7F706E-9738-4DDB-9089-F17F927E1247}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -199,6 +205,7 @@ Global {93E3B973-0FC4-4241-B7BB-064FB538FB50} = {5AD837BC-78F3-4543-8AA3-DF74D0DF94C0} {44AD321D-96D4-481E-BD41-D0B12A619833} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B} {82C0CD7D-2764-421A-8256-7E2304D5A6E7} = {E5637F81-2FB9-4CD7-900D-455363B142A7} + {EA7F706E-9738-4DDB-9089-F17F927E1247} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/samples/Preview/MediatorPattern/Mediator1/ExistingTypesOrchestration.cs b/samples/Preview/MediatorPattern/Mediator1/ExistingTypesOrchestration.cs new file mode 100644 index 00000000..b7c7721f --- /dev/null +++ b/samples/Preview/MediatorPattern/Mediator1/ExistingTypesOrchestration.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using McMaster.Extensions.CommandLineUtils; +using Microsoft.DurableTask; +using Microsoft.Extensions.Logging; + +namespace Preview.MediatorPattern.ExistingTypes; + +/** +* This sample shows mediator-pattern orchestrations and activities using existing types as their inputs. In this mode, +* the request object provides a distinct separate object as the input to the task. The below code has no real purpose +* nor demonstrates good ways to organize orchestrations or activities. The purpose is to demonstrate how the static +* 'CreateRequest' method way of using the mediator pattern. +* +* This is just one such way to leverage the mediator-pattern. Ultimately all the request object is all that is needed, +* how it is created is flexible. +*/ + +public class MediatorOrchestrator1 : TaskOrchestrator // Single generic means it has no output. Only input. +{ + public static IOrchestrationRequest CreateRequest(string propA, string propB) + => OrchestrationRequest.Create(nameof(MediatorOrchestrator1), new MyInput(propA, propB)); + + public override async Task RunAsync(TaskOrchestrationContext context, MyInput input) + { + string output = await context.RunAsync(MediatorSubOrchestrator1.CreateRequest(input.PropA)); + await context.RunAsync(WriteConsoleActivity1.CreateRequest(output)); + + output = await context.RunAsync(ExpandActivity1.CreateRequest(input.PropB)); + await context.RunAsync(WriteConsoleActivity1.CreateRequest(output)); + } +} + +public class MediatorSubOrchestrator1 : TaskOrchestrator +{ + public static IOrchestrationRequest CreateRequest(string input) + => OrchestrationRequest.Create(nameof(MediatorSubOrchestrator1), input); + + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + // Orchestrations create replay-safe loggers off the + ILogger logger = context.CreateReplaySafeLogger(); + logger.LogDebug("In MySubOrchestrator"); + return context.RunAsync(ExpandActivity1.CreateRequest($"{nameof(MediatorSubOrchestrator1)}: {input}")); + } +} + +public class WriteConsoleActivity1 : TaskActivity // Single generic means it has no output. Only input. +{ + readonly IConsole console; + + public WriteConsoleActivity1(IConsole console) // Dependency injection example. + { + this.console = console; + } + + public static IActivityRequest CreateRequest(string input) + => ActivityRequest.Create(nameof(WriteConsoleActivity1), input); + + public override Task RunAsync(TaskActivityContext context, string input) + { + this.console.WriteLine(input); + return Task.CompletedTask; + } +} + +public class ExpandActivity1 : TaskActivity +{ + readonly ILogger logger; + + public ExpandActivity1(ILogger logger) // Activities get logger from DI. + { + this.logger = logger; + } + + public static IActivityRequest CreateRequest(string input) + => ActivityRequest.Create(nameof(ExpandActivity1), input); + + public override Task RunAsync(TaskActivityContext context, string input) + { + this.logger.LogDebug("In ExpandActivity"); + return Task.FromResult($"Input received: {input}"); + } +} + +public record MyInput(string PropA, string PropB); diff --git a/samples/Preview/MediatorPattern/Mediator1/Mediator1Command.cs b/samples/Preview/MediatorPattern/Mediator1/Mediator1Command.cs new file mode 100644 index 00000000..efd5e6fe --- /dev/null +++ b/samples/Preview/MediatorPattern/Mediator1/Mediator1Command.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using McMaster.Extensions.CommandLineUtils; +using Microsoft.DurableTask; +using Preview.MediatorPattern.ExistingTypes; + +namespace Preview.MediatorPattern; + +[Command(Description = "Runs the first mediator sample")] +public class Mediator1Command : SampleCommandBase +{ + public static void Register(DurableTaskRegistry tasks) + { + tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddOrchestrator(); + tasks.AddOrchestrator(); + } + + protected override IBaseOrchestrationRequest GetRequest() + { + return MediatorOrchestrator1.CreateRequest("PropInputA", "PropInputB"); + } +} + + diff --git a/samples/Preview/MediatorPattern/Mediator2/Mediator2Command.cs b/samples/Preview/MediatorPattern/Mediator2/Mediator2Command.cs new file mode 100644 index 00000000..483bbce2 --- /dev/null +++ b/samples/Preview/MediatorPattern/Mediator2/Mediator2Command.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using McMaster.Extensions.CommandLineUtils; +using Microsoft.DurableTask; +using Preview.MediatorPattern.NewTypes; + +namespace Preview.MediatorPattern; + +[Command(Description = "Runs the second mediator sample")] +public class Mediator2Command : SampleCommandBase +{ + public static void Register(DurableTaskRegistry tasks) + { + tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddOrchestrator(); + tasks.AddOrchestrator(); + } + + protected override IBaseOrchestrationRequest GetRequest() + { + return new MediatorOrchestratorRequest("PropA", "PropB"); + } +} + + diff --git a/samples/Preview/MediatorPattern/Mediator2/NewTypesOrchestration.cs b/samples/Preview/MediatorPattern/Mediator2/NewTypesOrchestration.cs new file mode 100644 index 00000000..db866501 --- /dev/null +++ b/samples/Preview/MediatorPattern/Mediator2/NewTypesOrchestration.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using McMaster.Extensions.CommandLineUtils; +using Microsoft.DurableTask; +using Microsoft.Extensions.Logging; + +namespace Preview.MediatorPattern.NewTypes; + +/** +* This sample shows mediator-pattern orchestrations and activities using newly defined request types as their input. In +* this mode, the request object is the input to the task itself. The below code has no real purpose nor demonstrates +* good ways to organize orchestrations or activities. The purpose is to demonstrate how request objects can be defined +* manually and provided directly to RunAsync method. +* +* This is just one such way to leverage the mediator-pattern. Ultimately all the request object is all that is needed, +* how it is created is flexible. +*/ + +public record MediatorOrchestratorRequest(string PropA, string PropB) : IOrchestrationRequest +{ + public TaskName GetTaskName() => nameof(MediatorOrchestrator2); +} + +public class MediatorOrchestrator2 : TaskOrchestrator // Single generic means it has no output. Only input. +{ + public override async Task RunAsync(TaskOrchestrationContext context, MediatorOrchestratorRequest input) + { + string output = await context.RunAsync(new MediatorSubOrchestratorRequest(input.PropA)); + await context.RunAsync(new WriteConsoleActivityRequest(output)); + } +} + +public record MediatorSubOrchestratorRequest(string Value) : IOrchestrationRequest +{ + public TaskName GetTaskName() => nameof(MediatorSubOrchestrator2); +} + +public class MediatorSubOrchestrator2 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, MediatorSubOrchestratorRequest input) + { + // Orchestrations create replay-safe loggers off the + ILogger logger = context.CreateReplaySafeLogger(); + logger.LogDebug("In MySubOrchestrator"); + return context.RunAsync(new ExpandActivityRequest($"{nameof(MediatorSubOrchestrator2)}: {input.Value}")); + } +} + +public record WriteConsoleActivityRequest(string Value) : IActivityRequest +{ + public TaskName GetTaskName() => nameof(WriteConsoleActivity2); +} + +public class WriteConsoleActivity2 : TaskActivity // Single generic means it has no output. Only input. +{ + readonly IConsole console; + + public WriteConsoleActivity2(IConsole console) // Dependency injection example. + { + this.console = console; + } + + public override Task RunAsync(TaskActivityContext context, WriteConsoleActivityRequest input) + { + this.console.WriteLine(input.Value); + return Task.CompletedTask; + } +} + +public record ExpandActivityRequest(string Value) : IActivityRequest +{ + public TaskName GetTaskName() => nameof(ExpandActivity2); +} + +public class ExpandActivity2 : TaskActivity +{ + readonly ILogger logger; + + public ExpandActivity2(ILogger logger) // Activities get logger from DI. + { + this.logger = logger; + } + + public override Task RunAsync(TaskActivityContext context, ExpandActivityRequest input) + { + this.logger.LogDebug("In ExpandActivity"); + return Task.FromResult($"Input received: {input.Value}"); + } +} \ No newline at end of file diff --git a/samples/Preview/MediatorPattern/README.md b/samples/Preview/MediatorPattern/README.md new file mode 100644 index 00000000..88504a34 --- /dev/null +++ b/samples/Preview/MediatorPattern/README.md @@ -0,0 +1,56 @@ +# Mediator Pattern + +## Running this sample + +First sample: +``` cli +dotnet run Preview.csproj -- mediator1 +``` + +Second sample: +``` cli +dotnet run Preview.csproj -- mediator2 +``` + +**NOTE**: see [dotnet run](https://learn.microsoft.com/dotnet/core/tools/dotnet-run). The `--` with a space following it is important. + +## What is the mediator pattern? + +> In software engineering, the mediator pattern defines an object that encapsulates how a set of objects interact. This pattern is considered to be a behavioral pattern due to the way it can alter the program's running behavior. +> +> -- [wikipedia](https://en.wikipedia.org/wiki/Mediator_pattern) + +Specifically to Durable Task, this means using objects to assist with enqueueing of orchestrations, sub-orchestrations, and activities. These objects handle all of the following: + +1. Defining which `TaskOrchestrator` or `TaskActivity` to run. +2. Providing the input for the task to be ran. +3. Defining the output type of the task. + +The end result is the ability to invoke orchestrations and activities in a type-safe manner. + +## What does it look like? + +Instead of supplying the name, input, and return type of an orchestration or activity separately, instead a 'request' object is used to do all of these at once. + +Example: enqueueing an activity. + +Raw API: +``` CSharp +string result = await context.RunActivityAsync(nameof(MyActivity), input); +``` + +Explicit extension method [1]: +``` csharp +string result = await context.RunMyActivityAsync(input); +``` + +Mediator +``` csharp +string result = await context.RunAsync(MyActivity.CreateRequest(input)); + +// OR - it is up to individual developers which style they prefer. Can also be mixed and matched as seen fit. + +string result = await context.RunAsync(new MyActivityRequest(input)); +``` + +[1] - while the extension method is concise, having many extension methods off the same type can make intellisense a bit unwieldy. diff --git a/samples/Preview/Preview.csproj b/samples/Preview/Preview.csproj new file mode 100644 index 00000000..a80404af --- /dev/null +++ b/samples/Preview/Preview.csproj @@ -0,0 +1,20 @@ + + + + Exe + net6.0 + enable + + + + + + + + + + + + + + diff --git a/samples/Preview/Program.cs b/samples/Preview/Program.cs new file mode 100644 index 00000000..f6c4024d --- /dev/null +++ b/samples/Preview/Program.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Converters; +using Microsoft.DurableTask.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Preview; +using Preview.MediatorPattern; + +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureServices(services => + { + services.AddDurableTaskClient(builder => + { + // Configure options for this builder. Can be omitted if no options customization is needed. + builder.Configure(opt => { }); + builder.UseGrpc(); // multiple overloads available for providing gRPC information + + // AddDurableTaskClient allows for multiple named clients by passing in a name as the first argument. + // When using a non-default named client, you will need to make this call below to have the + // DurableTaskClient added directly to the DI container. Otherwise IDurableTaskClientProvider must be used + // to retrieve DurableTaskClients by name from the DI container. In this case, we are using the default + // name, so the line below is NOT required as it was already called for us. + builder.RegisterDirectly(); + }); + + services.AddDurableTaskWorker(builder => + { + // Configure options for this builder. Can be omitted if no options customization is needed. + builder.Configure(opt => { }); + + // Register orchestrators and activities. + builder.AddTasks(tasks => + { + Mediator1Command.Register(tasks); + Mediator2Command.Register(tasks); + }); + + builder.UseGrpc(); // multiple overloads available for providing gRPC information + }); + + // Can also configure worker and client options through all the existing options config methods. + // These are equivalent to the 'builder.Configure' calls above. + services.Configure(opt => { }); + services.Configure(opt => { }); + + // Registry can also be done via options pattern. This is equivalent to the 'builder.AddTasks' call above. + // You can use all the tools options pattern has available. For example, if you have multiple workers you could + // use ConfigureAll to add tasks to ALL workers in one go. Otherwise, you need to use + // named option configuration to register to specific workers (where the name matches the name passed to + // 'AddDurableTaskWorker(name?, builder)'). + services.Configure(registry => { }); + + // You can configure custom data converter multiple ways. One is through worker/client options configuration. + // Alternatively, data converter will be used from the service provider if available (as a singleton) AND no + // converter was explicitly set on the options. + services.AddSingleton(JsonDataConverter.Default); + }) + .UseCommandLineApplication(args) + .Build(); + +await host.RunCommandLineApplicationAsync(); diff --git a/samples/Preview/README.md b/samples/Preview/README.md new file mode 100644 index 00000000..5a66f896 --- /dev/null +++ b/samples/Preview/README.md @@ -0,0 +1,3 @@ +# Preview Samples + +Samples in this directory are for features which have not yet been fully released. They may be in a preview NuGet package, or not in any package at all yet. diff --git a/samples/Preview/Sample.cs b/samples/Preview/Sample.cs new file mode 100644 index 00000000..3b5c15b5 --- /dev/null +++ b/samples/Preview/Sample.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using McMaster.Extensions.CommandLineUtils; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Preview.MediatorPattern; + +namespace Preview; + +[Command(Name = "sample", Description = "Runs a provided sample.")] +[Subcommand(typeof(Mediator1Command), typeof(Mediator2Command))] +public class Sample +{ +} + +public abstract class SampleCommandBase +{ + public async Task OnExecuteAsync(IConsole console, DurableTaskClient client, CancellationToken cancellation) + { + IBaseOrchestrationRequest request = this.GetRequest(); + console.WriteLine($"Running {request.GetTaskName()}"); + string instanceId = await client.StartNewAsync(request, cancellation); + console.WriteLine($"Created instance: '{instanceId}'"); + await client.WaitForInstanceCompletionAsync(instanceId, cancellation); + console.WriteLine($"Instance completed: {instanceId}"); + } + + protected abstract IBaseOrchestrationRequest GetRequest(); +} diff --git a/src/Abstractions/ActivityRequest.cs b/src/Abstractions/ActivityRequest.cs new file mode 100644 index 00000000..bd671a0d --- /dev/null +++ b/src/Abstractions/ActivityRequest.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Represents the base request to run a . +/// +public interface IBaseActivityRequest +{ + /// + /// Gets the representing the to run. + /// + /// A . + /// + /// This is a function instead of a property so it is excluded in serialization without needing to use a + /// serialization library specific attribute to exclude it. + /// + TaskName GetTaskName(); +} + +/// +/// Represents a request to run a which returns . +/// +/// The result of the orchestrator that is to be ran. +public interface IActivityRequest : IBaseActivityRequest +{ +} + +/// +/// Represents a request to run a which has no return. +/// +public interface IActivityRequest : IActivityRequest +{ +} + +/// +/// Helpers for creating activity requests. +/// +public static class ActivityRequest +{ + /// + /// Gets an which has an explicitly provided input. + /// + /// + /// This is useful when you want to use an existing type for input (like ) and not derive an + /// entirely new type. + /// + /// The result type of the activity. + /// The name of the activity to run. + /// The input for the activity. + /// A request that can be used to enqueue an activity. + public static IActivityRequest Create(TaskName name, object? input = null) + => new Request(name, input); + + /// + /// Gets an which has an explicitly provided input. + /// + /// + /// This is useful when you want to use an existing type for input (like ) and not derive an + /// entirely new type. + /// + /// The name of the activity to run. + /// The input for the activity. + /// A request that can be used to enqueue an activity. + public static IActivityRequest Create(TaskName name, object? input = null) + => new Request(name, input); + + /// + /// Gets the activity input from a . + /// + /// The request to get input for. + /// The input. + internal static object? GetInput(this IBaseActivityRequest request) + { + if (request is IProvidesInput provider) + { + return provider.GetInput(); + } + + return request; + } + + class Request : RequestCore, IActivityRequest + { + public Request(TaskName name, object? input) + : base(name, input) + { + } + } + + class Request : RequestCore, IActivityRequest + { + public Request(TaskName name, object? input) + : base(name, input) + { + } + } + + class RequestCore : IBaseActivityRequest, IProvidesInput + { + readonly TaskName name; + readonly object? input; + + public RequestCore(TaskName name, object? input) + { + this.name = name; + this.input = input; + } + + public object? GetInput() => this.input; + + public TaskName GetTaskName() => this.name; + } +} diff --git a/src/Abstractions/IProvidesInput.cs b/src/Abstractions/IProvidesInput.cs new file mode 100644 index 00000000..a89c3b7e --- /dev/null +++ b/src/Abstractions/IProvidesInput.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Contract for providing input to an orchestration or activity. +/// +interface IProvidesInput +{ + /// + /// Gets the input for the orchestration or activity. + /// + /// The input value. + /// + /// This is a method and not a property to ensure it is not included in serialization. + /// + object? GetInput(); +} diff --git a/src/Abstractions/InputHelper.cs b/src/Abstractions/InputHelper.cs new file mode 100644 index 00000000..650c79c4 --- /dev/null +++ b/src/Abstractions/InputHelper.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Orchestration/Activity input helpers. +/// +static class InputHelper +{ + /// + /// Due to nullable reference types being static analysis only, we need to do our best efforts for validating the + /// input type, but also give control of nullability to the implementation. It is not ideal, but we do not want to + /// force 'TInput?' on the RunAsync implementation. + /// + /// The input type of the orchestration or activity. + /// The input object. + /// The input converted to the desired type. + public static void ValidateInput(object? input, out TInput typedInput) + { + if (input is TInput typed) + { + // Quick pattern check. + typedInput = typed; + return; + } + else if (input is not null && typeof(TInput) != input.GetType()) + { + throw new ArgumentException($"Input type '{input?.GetType()}' does not match expected type '{typeof(TInput)}'."); + } + + // Input is null and did not match a nullable value type. We do not have enough information to tell if it is + // valid or not. We will have to defer this decision to the implementation. Additionally, we will coerce a null + // input to a default value type here. This is to keep the two RunAsync(context, default) overloads to have + // identical behavior. + typedInput = default!; + return; + } +} diff --git a/src/Abstractions/OrchestrationRequest.cs b/src/Abstractions/OrchestrationRequest.cs new file mode 100644 index 00000000..839bf523 --- /dev/null +++ b/src/Abstractions/OrchestrationRequest.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Represents the base request to run a . +/// +public interface IBaseOrchestrationRequest +{ + /// + /// Gets the representing the to run. + /// + /// A . + /// + /// This is a function instead of a property so it is excluded in serialization without needing to use a + /// serialization library specific attribute to exclude it. + /// + TaskName GetTaskName(); +} + +/// +/// Represents a request to run a which returns . +/// +/// The result of the orchestrator that is to be ran. +public interface IOrchestrationRequest : IBaseOrchestrationRequest +{ +} + +/// +/// Represents a request to run a which has no return. +/// +public interface IOrchestrationRequest : IOrchestrationRequest +{ +} + +/// +/// Helpers for creating orchestration requests. +/// +public static class OrchestrationRequest +{ + /// + /// Gets an which has an explicitly provided input. + /// + /// + /// This is useful when you want to use an existing type for input (like ) and not derive an + /// entirely new type. + /// + /// The result type of the orchestration. + /// The name of the orchestration to run. + /// The input for the orchestration. + /// A request that can be used to enqueue an orchestration. + public static IOrchestrationRequest Create(TaskName name, object? input = null) + => new Request(name, input); + + /// + /// Gets an which has an explicitly provided input. + /// + /// + /// This is useful when you want to use an existing type for input (like ) and not derive an + /// entirely new type. + /// + /// The name of the orchestration to run. + /// The input for the orchestration. + /// A request that can be used to enqueue an orchestration. + public static IOrchestrationRequest Create(TaskName name, object? input = null) + => new Request(name, input); + + /// + /// Gets the orchestration input from a . + /// + /// The request to get input for. + /// The input. + internal static object? GetInput(this IBaseOrchestrationRequest request) + { + if (request is IProvidesInput provider) + { + return provider.GetInput(); + } + + return request; + } + + class Request : RequestCore, IOrchestrationRequest + { + public Request(TaskName name, object? input) + : base(name, input) + { + } + } + + class Request : RequestCore, IOrchestrationRequest + { + public Request(TaskName name, object? input) + : base(name, input) + { + } + } + + class RequestCore : IBaseOrchestrationRequest, IProvidesInput + { + readonly TaskName name; + readonly object? input; + + public RequestCore(TaskName name, object? input) + { + this.name = name; + this.input = input; + } + + public object? GetInput() => this.input; + + public TaskName GetTaskName() => this.name; + } +} diff --git a/src/Abstractions/TaskActivity.cs b/src/Abstractions/TaskActivity.cs index 65e82e2f..5cf86b54 100644 --- a/src/Abstractions/TaskActivity.cs +++ b/src/Abstractions/TaskActivity.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Diagnostics.CodeAnalysis; - namespace Microsoft.DurableTask; /// @@ -74,11 +72,7 @@ public abstract class TaskActivity : ITaskActivity async Task ITaskActivity.RunAsync(TaskActivityContext context, object? input) { Check.NotNull(context, nameof(context)); - if (!IsValidInput(input, out TInput? typedInput)) - { - throw new ArgumentException($"Input type '{input?.GetType()}' does not match expected type '{typeof(TInput)}'."); - } - + InputHelper.ValidateInput(input, out TInput typedInput); return await this.RunAsync(context, typedInput); } @@ -89,31 +83,31 @@ public abstract class TaskActivity : ITaskActivity /// The deserialized activity input. /// The output of the activity as a task. public abstract Task RunAsync(TaskActivityContext context, TInput input); +} - /// - /// Due to nullable reference types being static analysis only, we need to do our best efforts for validating the - /// input type, but also give control of nullability to the implementation. It is not ideal, but we do not want to - /// force 'TInput?' on the RunAsync implementation. - /// - static bool IsValidInput(object? input, [NotNullWhen(true)] out TInput? typedInput) - { - if (input is TInput typed) - { - // Quick pattern check. - typedInput = typed; - return true; - } - else if (input is not null && typeof(TInput) != input.GetType()) - { - typedInput = default; - return false; - } +/// +public abstract class TaskActivity : ITaskActivity +{ + /// + Type ITaskActivity.InputType => typeof(TInput); + + /// + Type ITaskActivity.OutputType => typeof(Unit); - // Input is null and did not match a nullable value type. We do not have enough information to tell if it is - // valid or not. We will have to defer this decision to the implementation. Additionally, we will coerce a null - // input to a default value type here. This is to keep the two RunAsync(context, default) overloads to have - // identical behavior. - typedInput = default!; - return true; + /// + async Task ITaskActivity.RunAsync(TaskActivityContext context, object? input) + { + Check.NotNull(context, nameof(context)); + InputHelper.ValidateInput(input, out TInput typedInput); + await this.RunAsync(context, typedInput); + return Unit.Value; } + + /// + /// Override to implement async (non-blocking) task activity logic. + /// + /// Provides access to additional context for the current activity execution. + /// The deserialized activity input. + /// The output of the activity as a task. + public abstract Task RunAsync(TaskActivityContext context, TInput input); } diff --git a/src/Abstractions/TaskOrchestrationContextRequestExtensions.cs b/src/Abstractions/TaskOrchestrationContextRequestExtensions.cs new file mode 100644 index 00000000..f3e716cd --- /dev/null +++ b/src/Abstractions/TaskOrchestrationContextRequestExtensions.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Extensions for for strongly-typed requests. +/// +public static class TaskOrchestrationContextRequestExtensions +{ + /// + /// Runs the sub-orchestration described by with as the + /// input to the orchestration itself. + /// + /// The result type of the orchestration. + /// The context used to run the orchestration. + /// The orchestration request. + /// The task options. + /// The result of the orchestration. + public static Task RunAsync( + this TaskOrchestrationContext context, IOrchestrationRequest request, TaskOptions? options = null) + { + Check.NotNull(context); + Check.NotNull(request); + TaskName name = request.GetTaskName(); + return context.CallSubOrchestratorAsync(name, request.GetInput(), options); + } + + /// + /// Runs the sub-orchestration described by with as the + /// input to the orchestration itself. + /// + /// The context used to run the orchestration. + /// The orchestration request. + /// The task options. + /// A task that completes when the orchestration completes. + public static Task RunAsync( + this TaskOrchestrationContext context, IOrchestrationRequest request, TaskOptions? options = null) + { + Check.NotNull(context); + Check.NotNull(request); + TaskName name = request.GetTaskName(); + return context.CallSubOrchestratorAsync(name, request.GetInput(), options); + } + + /// + /// Runs the activity described by with as the + /// input to the activity itself. + /// + /// The result type of the activity. + /// The context used to run the activity. + /// The activity request. + /// The task options. + /// The result of the activity. + public static Task RunAsync( + this TaskOrchestrationContext context, IActivityRequest request, TaskOptions? options = null) + { + Check.NotNull(context); + Check.NotNull(request); + TaskName name = request.GetTaskName(); + return context.CallActivityAsync(name, request.GetInput(), options); + } + + /// + /// Runs the activity described by with as the + /// input to the activity itself. + /// + /// The context used to run the activity. + /// The activity request. + /// The task options. + /// A task that completes when the activity completes. + public static Task RunAsync( + this TaskOrchestrationContext context, IActivityRequest request, TaskOptions? options = null) + { + Check.NotNull(context); + Check.NotNull(request); + TaskName name = request.GetTaskName(); + return context.CallActivityAsync(name, request.GetInput(), options); + } +} diff --git a/src/Abstractions/TaskOrchestrator.cs b/src/Abstractions/TaskOrchestrator.cs index 07dca1e5..c736fa82 100644 --- a/src/Abstractions/TaskOrchestrator.cs +++ b/src/Abstractions/TaskOrchestrator.cs @@ -1,9 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.Logging; - namespace Microsoft.DurableTask; /// @@ -125,11 +122,7 @@ public abstract class TaskOrchestrator : ITaskOrchestrator async Task ITaskOrchestrator.RunAsync(TaskOrchestrationContext context, object? input) { Check.NotNull(context, nameof(context)); - if (!IsValidInput(input, out TInput? typedInput)) - { - throw new ArgumentException($"Input type '{input?.GetType()}' does not match expected type '{typeof(TInput)}'."); - } - + InputHelper.ValidateInput(input, out TInput typedInput); return await this.RunAsync(context, typedInput); } @@ -140,31 +133,56 @@ public abstract class TaskOrchestrator : ITaskOrchestrator /// The deserialized orchestration input. /// The output of the orchestration as a task. public abstract Task RunAsync(TaskOrchestrationContext context, TInput input); +} + +/// . +public abstract class TaskOrchestrator : ITaskOrchestrator +{ + /// + public Type InputType => typeof(TInput); + + /// + public Type OutputType => typeof(Unit); + + /// + async Task ITaskOrchestrator.RunAsync(TaskOrchestrationContext context, object? input) + { + Check.NotNull(context, nameof(context)); + InputHelper.ValidateInput(input, out TInput typedInput); + await this.RunAsync(context, typedInput); + return Unit.Value; + } /// - /// Due to nullable reference types being static analysis only, we need to do our best efforts for validating the - /// input type, but also give control of nullability to the implementation. It is not ideal, but we do not want to - /// force 'TInput?' on the RunAsync implementation. + /// Override to implement task orchestrator logic. /// - static bool IsValidInput(object? input, [NotNullWhen(true)] out TInput? typedInput) + /// The task orchestrator's context. + /// The deserialized orchestration input. + /// A task that completes when the orchestration is complete. + public abstract Task RunAsync(TaskOrchestrationContext context, TInput input); +} + +/// . +public abstract class TaskOrchestrator : ITaskOrchestrator +{ + /// + public Type InputType => typeof(Unit); + + /// + public Type OutputType => typeof(Unit); + + /// + async Task ITaskOrchestrator.RunAsync(TaskOrchestrationContext context, object? input) { - if (input is TInput typed) - { - // Quick pattern check. - typedInput = typed; - return true; - } - else if (input is not null && typeof(TInput) != input.GetType()) - { - typedInput = default; - return false; - } - - // Input is null and did not match a nullable value type. We do not have enough information to tell if it is - // valid or not. We will have to defer this decision to the implementation. Additionally, we will coerce a null - // input to a default value type here. This is to keep the two RunAsync(context, default) overloads to have - // identical behavior. - typedInput = default!; - return true; + Check.NotNull(context, nameof(context)); + await this.RunAsync(context); + return Unit.Value; } + + /// + /// Override to implement task orchestrator logic. + /// + /// The task orchestrator's context. + /// A task that completes when the orchestration is complete. + public abstract Task RunAsync(TaskOrchestrationContext context); } diff --git a/src/Abstractions/Unit.cs b/src/Abstractions/Unit.cs new file mode 100644 index 00000000..d1d24f89 --- /dev/null +++ b/src/Abstractions/Unit.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Represents a result. +/// +/// +/// Modeled after https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/unit-type. +/// +public readonly struct Unit : IEquatable, IComparable +{ +#pragma warning disable CA1801 // unused parameters +#pragma warning disable IDE0060 // unused parameters + + static readonly Unit RefValue; + + /// + /// Gets the default value for . + /// + public static ref readonly Unit Value => ref RefValue; + + /// + /// Gets the task result for a . + /// + public static Task Task { get; } = System.Threading.Tasks.Task.FromResult(RefValue); + + /// + /// Compares two units for equality. Always true. + /// + /// The left unit. + /// The right unit. + /// Always true. + public static bool operator ==(Unit left, Unit right) => true; + + /// + /// Compares two units for inequality. Always false. + /// + /// The left unit. + /// The right unit. + /// Always false. + public static bool operator !=(Unit left, Unit right) => !true; + + /// + /// Compares two units. Always false. + /// + /// The left unit. + /// The right unit. + /// Always false. + public static bool operator <(Unit left, Unit right) => false; + + /// + /// Compares two units. Always true. + /// + /// The left unit. + /// The right unit. + /// Always true. + public static bool operator <=(Unit left, Unit right) => true; + + /// + /// Compares two units. Always false. + /// + /// The left unit. + /// The right unit. + /// Always false. + public static bool operator >(Unit left, Unit right) => false; + + /// + /// Compares two units. Always true. + /// + /// The left unit. + /// The right unit. + /// Always true. + public static bool operator >=(Unit left, Unit right) => true; + + /// + public int CompareTo(Unit other) => 0; + + /// + public bool Equals(Unit other) => true; + + /// + public override bool Equals(object obj) => obj is Unit; + + /// + public override int GetHashCode() => 0; + + /// + public override string ToString() => "()"; // Same as F# Unit string representation. + +#pragma warning restore CA1801 // unused parameters +#pragma warning restore IDE0060 // unused parameters +} diff --git a/src/Client/Core/DependencyInjection/DurableTaskClientExtensions.cs b/src/Client/Core/DurableTaskClientExtensions.cs similarity index 51% rename from src/Client/Core/DependencyInjection/DurableTaskClientExtensions.cs rename to src/Client/Core/DurableTaskClientExtensions.cs index 860901f4..989b6ac0 100644 --- a/src/Client/Core/DependencyInjection/DurableTaskClientExtensions.cs +++ b/src/Client/Core/DurableTaskClientExtensions.cs @@ -51,4 +51,49 @@ public static Task PurgeInstancesAsync( DateTimeOffset? createdTo, CancellationToken cancellation = default) => PurgeInstancesAsync(client, createdFrom, createdTo, null, cancellation); + + /// + /// Starts a new orchestration instance for execution. + /// + /// The client to schedule the orchestration with. + /// The orchestration request to schedule. + /// The cancellation token. + /// + /// A task that completes when the orchestration instance is successfully scheduled. The value of this task is the + /// instance ID the orchestration was scheduled with. + /// + /// + public static Task StartNewAsync( + this DurableTaskClient client, IBaseOrchestrationRequest request, CancellationToken cancellation) + { + Check.NotNull(client); + Check.NotNull(request); + TaskName name = request.GetTaskName(); + return client.ScheduleNewOrchestrationInstanceAsync(name, request.GetInput(), cancellation); + } + + /// + /// Starts a new orchestration instance for execution. + /// + /// The client to schedule the orchestration with. + /// The orchestration request to schedule. + /// The options for starting this orchestration with. + /// The cancellation token. + /// + /// A task that completes when the orchestration instance is successfully scheduled. The value of this task is + /// the instance ID of the scheduled orchestration instance. If a non-null instance ID was provided via + /// , the same value will be returned by the completed task. + /// + /// + public static Task StartNewAsync( + this DurableTaskClient client, + IBaseOrchestrationRequest request, + StartOrchestrationOptions? options = null, + CancellationToken cancellation = default) + { + Check.NotNull(client); + Check.NotNull(request); + TaskName name = request.GetTaskName(); + return client.ScheduleNewOrchestrationInstanceAsync(name, request.GetInput(), options, cancellation); + } } diff --git a/test/Abstractions.Tests/ActivityRequestTests.cs b/test/Abstractions.Tests/ActivityRequestTests.cs new file mode 100644 index 00000000..5f9618aa --- /dev/null +++ b/test/Abstractions.Tests/ActivityRequestTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + + +namespace Microsoft.DurableTask.Tests; + +public class ActivityRequestTests +{ + [Theory] + [InlineData(null)] + [InlineData(10)] + public void Create_Success(int? input) + { + TaskName name = "Test"; + IActivityRequest request = ActivityRequest.Create(name, input); + + request.GetInput().Should().Be(input); + request.GetTaskName().Should().Be(name); + } + + [Theory] + [InlineData(null)] + [InlineData("input")] + public void Create_OfT_Success(string input) + { + TaskName name = "Test"; + IActivityRequest request = ActivityRequest.Create(name, input); + + request.GetInput().Should().Be(input); + request.GetTaskName().Should().Be(name); + } + + [Theory] + [InlineData(null)] + [InlineData("input")] + public async Task RunRequest_Success(string input) + { + TaskName name = "Test"; + IActivityRequest request = ActivityRequest.Create(name, input); + Mock context = new(MockBehavior.Strict); + context.Setup(m => m.CallActivityAsync(name, input, null)).Returns(Task.CompletedTask); + + await context.Object.RunAsync(request); + + context.Verify(m => m.CallActivityAsync(name, input, null), Times.Once); + } + + [Theory] + [InlineData(null)] + [InlineData("input")] + public async Task RunRequest_OfT_Success(string input) + { + TaskName name = "Test"; + IActivityRequest request = ActivityRequest.Create(name, input); + Mock context = new(MockBehavior.Strict); + context.Setup(m => m.CallActivityAsync(name, input, null)).Returns(Task.FromResult(0)); + + await context.Object.RunAsync(request); + + context.Verify(m => m.CallActivityAsync(name, input, null), Times.Once); + } + + [Fact] + public async Task RunRequest2_Success() + { + IActivityRequest request = new DirectRequest(); + Mock context = new(MockBehavior.Strict); + context.Setup(m => m.CallActivityAsync(DirectRequest.Name, request, null)).Returns(Task.CompletedTask); + + await context.Object.RunAsync(request); + + context.Verify(m => m.CallActivityAsync(DirectRequest.Name, request, null), Times.Once); + } + + [Fact] + public async Task RunRequest2_OfT_Success() + { + TaskName name = "Test"; + IActivityRequest request = new DirectRequest2(); + Mock context = new(MockBehavior.Strict); + context.Setup(m => m.CallActivityAsync(DirectRequest.Name, request, null)) + .Returns(Task.FromResult(0)); + + await context.Object.RunAsync(request); + + context.Verify(m => m.CallActivityAsync(DirectRequest.Name, request, null), Times.Once); + } + + class DirectRequest : IActivityRequest + { + public static readonly TaskName Name = "DirectRequest"; + + public TaskName GetTaskName() => Name; + } + + class DirectRequest2 : IActivityRequest + { + public TaskName GetTaskName() => DirectRequest.Name; + } +} diff --git a/test/Abstractions.Tests/OrchestrationRequestTests.cs b/test/Abstractions.Tests/OrchestrationRequestTests.cs new file mode 100644 index 00000000..695bc310 --- /dev/null +++ b/test/Abstractions.Tests/OrchestrationRequestTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + + +namespace Microsoft.DurableTask.Tests; + +public class OrchestrationRequestTests +{ + [Theory] + [InlineData(null)] + [InlineData(10)] + public void Create_Success(int? input) + { + TaskName name = "Test"; + IOrchestrationRequest request = OrchestrationRequest.Create(name, input); + + request.GetInput().Should().Be(input); + request.GetTaskName().Should().Be(name); + } + + [Theory] + [InlineData(null)] + [InlineData("input")] + public void Create_OfT_Success(string input) + { + TaskName name = "Test"; + IOrchestrationRequest request = OrchestrationRequest.Create(name, input); + + request.GetInput().Should().Be(input); + request.GetTaskName().Should().Be(name); + } + + [Theory] + [InlineData(null)] + [InlineData("input")] + public async Task RunRequest_Success(string input) + { + TaskName name = "Test"; + IOrchestrationRequest request = OrchestrationRequest.Create(name, input); + Mock context = new(MockBehavior.Strict); + context.Setup(m => m.CallSubOrchestratorAsync(name, input, null)).Returns(Task.CompletedTask); + + await context.Object.RunAsync(request); + + context.Verify(m => m.CallSubOrchestratorAsync(name, input, null), Times.Once); + } + + [Theory] + [InlineData(null)] + [InlineData("input")] + public async Task RunRequest_OfT_Success(string input) + { + TaskName name = "Test"; + IOrchestrationRequest request = OrchestrationRequest.Create(name, input); + Mock context = new(MockBehavior.Strict); + context.Setup(m => m.CallSubOrchestratorAsync(name, input, null)).Returns(Task.FromResult(0)); + + await context.Object.RunAsync(request); + + context.Verify(m => m.CallSubOrchestratorAsync(name, input, null), Times.Once); + } + + [Fact] + public async Task RunRequest2_Success() + { + IOrchestrationRequest request = new DirectRequest(); + Mock context = new(MockBehavior.Strict); + context.Setup(m => m.CallSubOrchestratorAsync(DirectRequest.Name, request, null)).Returns(Task.CompletedTask); + + await context.Object.RunAsync(request); + + context.Verify(m => m.CallSubOrchestratorAsync(DirectRequest.Name, request, null), Times.Once); + } + + [Fact] + public async Task RunRequest2_OfT_Success() + { + TaskName name = "Test"; + IOrchestrationRequest request = new DirectRequest2(); + Mock context = new(MockBehavior.Strict); + context.Setup(m => m.CallSubOrchestratorAsync(DirectRequest.Name, request, null)) + .Returns(Task.FromResult(0)); + + await context.Object.RunAsync(request); + + context.Verify(m => m.CallSubOrchestratorAsync(DirectRequest.Name, request, null), Times.Once); + } + + class DirectRequest : IOrchestrationRequest + { + public static readonly TaskName Name = "DirectRequest"; + + public TaskName GetTaskName() => Name; + } + + class DirectRequest2 : IOrchestrationRequest + { + public TaskName GetTaskName() => DirectRequest.Name; + } +}