diff --git a/.gitignore b/.gitignore index f49bbfa2f..a4a7aced3 100644 --- a/.gitignore +++ b/.gitignore @@ -351,6 +351,7 @@ MigrationBackup/ # Rider (cross platform .NET/C# tools) working folder .idea/ +.worktrees/ AzuriteConfig __azurite_db_* __blobstorage__ diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 0b8ef9359..9c23a6d80 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215 @@ -115,6 +115,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NamespaceGenerationSample", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReplaySafeLoggerFactorySample", "samples\ReplaySafeLoggerFactorySample\ReplaySafeLoggerFactorySample.csproj", "{8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkerVersioningSample", "samples\WorkerVersioningSample\WorkerVersioningSample.csproj", "{26988639-D204-4E0B-80BE-F4E11952DFF8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{D4587EC0-1B16-8420-7502-A967139249D4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{53193780-CD18-2643-6953-C26F59EAEDF5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerOrchestratorVersioningSample", "samples\PerOrchestratorVersioningSample\PerOrchestratorVersioningSample.csproj", "{1E30F09F-1ADA-4375-81CC-F0FBC74D5621}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActivityVersioningSample", "samples\ActivityVersioningSample\ActivityVersioningSample.csproj", "{3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -701,7 +711,42 @@ Global {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 - + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Debug|x64.ActiveCfg = Debug|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Debug|x64.Build.0 = Debug|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Debug|x86.ActiveCfg = Debug|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Debug|x86.Build.0 = Debug|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Release|Any CPU.Build.0 = Release|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Release|x64.ActiveCfg = Release|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Release|x64.Build.0 = Release|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Release|x86.ActiveCfg = Release|Any CPU + {26988639-D204-4E0B-80BE-F4E11952DFF8}.Release|x86.Build.0 = Release|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Debug|x64.ActiveCfg = Debug|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Debug|x64.Build.0 = Debug|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Debug|x86.ActiveCfg = Debug|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Debug|x86.Build.0 = Debug|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Release|Any CPU.Build.0 = Release|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Release|x64.ActiveCfg = Release|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Release|x64.Build.0 = Release|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Release|x86.ActiveCfg = Release|Any CPU + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621}.Release|x86.Build.0 = Release|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Debug|x64.ActiveCfg = Debug|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Debug|x64.Build.0 = Debug|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Debug|x86.ActiveCfg = Debug|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Debug|x86.Build.0 = Debug|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Release|Any CPU.Build.0 = Release|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Release|x64.ActiveCfg = Release|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Release|x64.Build.0 = Release|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Release|x86.ActiveCfg = Release|Any CPU + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -759,7 +804,11 @@ Global {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {5A69FD28-D814-490E-A76B-B0A5F88C25B2} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} - + {26988639-D204-4E0B-80BE-F4E11952DFF8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {D4587EC0-1B16-8420-7502-A967139249D4} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} + {53193780-CD18-2643-6953-C26F59EAEDF5} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} + {1E30F09F-1ADA-4375-81CC-F0FBC74D5621} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/README.md b/README.md index 7226f2011..6a63b3dfa 100644 --- a/README.md +++ b/README.md @@ -155,15 +155,50 @@ public class SayHelloTyped : TaskActivity You can find the full sample file, including detailed comments, at [samples/AzureFunctionsApp/HelloCitiesTyped.cs](samples/AzureFunctionsApp/HelloCitiesTyped.cs). +### Versioned class-based orchestrators (standalone worker) + +Standalone worker projects can register multiple class-based orchestrators under the same durable task name when each class declares a unique `[DurableTaskVersion]`. Start a specific implementation by setting `StartOrchestrationOptions.Version`. + +```csharp +[DurableTask("OrderWorkflow")] +[DurableTaskVersion("v1")] +public sealed class OrderWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) + => Task.FromResult($"v1:{input}"); +} + +[DurableTask("OrderWorkflow")] +[DurableTaskVersion("v2")] +public sealed class OrderWorkflowV2 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) + => Task.FromResult($"v2:{input}"); +} + +string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + "OrderWorkflow", + input: 5, + new StartOrchestrationOptions { Version = new TaskVersion("v2") }); +``` + +Use `ContinueAsNewOptions.NewVersion` to migrate long-running orchestrations at a replay-safe boundary. + +> Do not combine per-orchestrator `[DurableTaskVersion]` routing with `DurableTaskWorkerOptions.Versioning` (or `UseVersioning(...)`). Both features use the orchestration instance version field, so worker-level version checks can reject per-orchestrator versions before class-based routing occurs. +> +> Azure Functions projects do not support same-name multi-version class-based orchestrators in v1. The source generator reports a diagnostic instead of generating colliding triggers. + ### Compatibility with Durable Functions in-process This SDK is *not* compatible with Durable Functions for the .NET *in-process* worker. It only works with the newer out-of-process .NET Isolated worker. -## Usage with the Durable Task Scheduler +## Usage with Durable Task Scheduler + +Durable Task Scheduler provides durable execution in Azure. Durable execution is a fault-tolerant approach to running code that handles failures and interruptions through automatic retries and state persistence. -The Durable Task Scheduler for Azure Functions is a managed backend that is currently in preview. Durable Functions apps can use the Durable Task Scheduler as one of its [supported storage providers](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-storage-providers). +This SDK can also be used with the Durable Task Scheduler directly, without any Durable Functions dependency. For getting started, you can find documentation and samples [here](https://learn.microsoft.com/en-us/azure/azure-functions/durable/what-is-durable-task). -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 runnable DTS emulator examples that demonstrate versioning, see the [WorkerVersioningSample](samples/WorkerVersioningSample/README.md) (deployment-based versioning), the [PerOrchestratorVersioningSample](samples/PerOrchestratorVersioningSample/README.md) (multi-version routing with `[DurableTaskVersion]`), and the [ActivityVersioningSample](samples/ActivityVersioningSample/README.md) (activity versioning with inherited defaults and explicit override support). ## Obtaining the Protobuf definitions diff --git a/samples/ActivityVersioningSample/ActivityVersioningSample.csproj b/samples/ActivityVersioningSample/ActivityVersioningSample.csproj new file mode 100644 index 000000000..e19bb7314 --- /dev/null +++ b/samples/ActivityVersioningSample/ActivityVersioningSample.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0;net10.0 + enable + + + + + + + + + + + + + + + diff --git a/samples/ActivityVersioningSample/Program.cs b/samples/ActivityVersioningSample/Program.cs new file mode 100644 index 000000000..ea80cd246 --- /dev/null +++ b/samples/ActivityVersioningSample/Program.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates activity versioning with [DurableTaskVersion]. +// Versioned orchestrators and versioned activities can share the same logical +// durable task names in one worker process. Plain activity calls inherit the +// orchestration instance version by default, while version-qualified helpers +// can explicitly override that routing when needed. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// Read the DTS connection string from configuration. +string connectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException( + "Set DURABLE_TASK_SCHEDULER_CONNECTION_STRING. " + + "For the local emulator: Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"); + +// AddAllGeneratedTasks() registers every [DurableTask]-annotated class in this +// project, including both versions of the orchestration and activity classes. +builder.Services.AddDurableTaskWorker(wb => +{ + wb.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + wb.UseDurableTaskScheduler(connectionString); +}); + +// Configure the client. Unlike worker-level versioning, the client does not +// stamp a single default version for every instance. +builder.Services.AddDurableTaskClient(cb => cb.UseDurableTaskScheduler(connectionString)); + +IHost host = builder.Build(); +await host.StartAsync(); + +await using DurableTaskClient client = host.Services.GetRequiredService(); + +Console.WriteLine("=== Activity versioning ([DurableTaskVersion]) ==="); +Console.WriteLine(); + +Console.WriteLine("Scheduling CheckoutWorkflow v1 ..."); +string v1Id = await client.ScheduleNewCheckoutWorkflow_1InstanceAsync(5); +OrchestrationMetadata v1 = await client.WaitForInstanceCompletionAsync(v1Id, getInputsAndOutputs: true); +Console.WriteLine($" Result: {v1.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Scheduling CheckoutWorkflow v2 ..."); +string v2Id = await client.ScheduleNewCheckoutWorkflow_2InstanceAsync(5); +OrchestrationMetadata v2 = await client.WaitForInstanceCompletionAsync(v2Id, getInputsAndOutputs: true); +Console.WriteLine($" Result: {v2.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Scheduling CheckoutWorkflow v2 with explicit ShippingQuote v1 override ..."); +string overrideId = await client.ScheduleNewOrchestrationInstanceAsync( + "ExplicitOverrideCheckoutWorkflow", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("2"), + }); +OrchestrationMetadata overrideResult = await client.WaitForInstanceCompletionAsync(overrideId, getInputsAndOutputs: true); +Console.WriteLine($" Result: {overrideResult.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Done! Both versions ran in the same worker process."); +Console.WriteLine("Default activity calls inherit the orchestration version, but versioned helpers can explicitly override it."); + +await host.StopAsync(); + +/// +/// CheckoutWorkflow v1 - default activity calls inherit orchestration version "1". +/// +[DurableTask("CheckoutWorkflow")] +[DurableTaskVersion("1")] +public sealed class CheckoutWorkflowV1 : TaskOrchestrator +{ + /// + public override async Task RunAsync(TaskOrchestrationContext context, int itemCount) + { + string quote = await context.CallActivityAsync("ShippingQuote", itemCount); + return $"Workflow v1 -> {quote}"; + } +} + +/// +/// CheckoutWorkflow v2 - default activity calls inherit orchestration version "2". +/// +[DurableTask("CheckoutWorkflow")] +[DurableTaskVersion("2")] +public sealed class CheckoutWorkflowV2 : TaskOrchestrator +{ + /// + public override async Task RunAsync(TaskOrchestrationContext context, int itemCount) + { + string quote = await context.CallActivityAsync("ShippingQuote", itemCount); + return $"Workflow v2 -> {quote}"; + } +} + +/// +/// CheckoutWorkflow v2 - explicitly overrides the inherited activity version. +/// +[DurableTask("ExplicitOverrideCheckoutWorkflow")] +[DurableTaskVersion("2")] +public sealed class ExplicitOverrideCheckoutWorkflowV2 : TaskOrchestrator +{ + /// + public override async Task RunAsync(TaskOrchestrationContext context, int itemCount) + { + string quote = await context.CallShippingQuote_1Async(itemCount); + return $"Workflow v2 explicit override -> {quote}"; + } +} + +/// +/// ShippingQuote v1 - uses a flat shipping charge. +/// +[DurableTask("ShippingQuote")] +[DurableTaskVersion("1")] +public sealed class ShippingQuoteV1 : TaskActivity +{ + /// + public override Task RunAsync(TaskActivityContext context, int itemCount) + { + int total = (itemCount * 10) + 7; + return Task.FromResult($"activity v1 quote: ${total} (flat $7 shipping)"); + } +} + +/// +/// ShippingQuote v2 - applies a bulk discount and cheaper shipping. +/// +[DurableTask("ShippingQuote")] +[DurableTaskVersion("2")] +public sealed class ShippingQuoteV2 : TaskActivity +{ + /// + public override Task RunAsync(TaskActivityContext context, int itemCount) + { + int total = (itemCount * 10) + 5; + if (itemCount >= 5) + { + total -= 10; + } + + return Task.FromResult($"activity v2 quote: ${total} ($10 bulk discount + $5 shipping)"); + } +} diff --git a/samples/ActivityVersioningSample/README.md b/samples/ActivityVersioningSample/README.md new file mode 100644 index 000000000..13c459da3 --- /dev/null +++ b/samples/ActivityVersioningSample/README.md @@ -0,0 +1,71 @@ +# Activity Versioning Sample + +This sample demonstrates activity versioning with `[DurableTaskVersion]`, where multiple implementations of the same logical activity name coexist in one worker process and can be selected either by the orchestration instance version or by an explicit version-qualified helper. + +## What it shows + +- Two classes share the same `[DurableTask("ShippingQuote")]` name but have different `[DurableTaskVersion]` values +- Two versions of `CheckoutWorkflow` call the same logical activity name in one worker process using the default inherited-routing behavior +- The orchestration instance version is still the default for activity scheduling, so `CheckoutWorkflow` v1 routes to `ShippingQuote` v1 and `CheckoutWorkflow` v2 routes to `ShippingQuote` v2 +- Version-qualified activity helpers like `CallShippingQuote_1Async()` and `CallShippingQuote_2Async()` now explicitly select those versions when called from an orchestration +- A third orchestration demonstrates explicitly overriding a `v2` orchestration to call the `ShippingQuote` v1 helper +- `AddAllGeneratedTasks()` registers both orchestration and activity versions automatically + +## Prerequisites + +- .NET 8.0 or 10.0 SDK +- [Docker](https://www.docker.com/get-started) + +## Running the Sample + +### 1. Start the DTS emulator + +```bash +docker run --name durabletask-emulator -d -p 8080:8080 -e ASPNETCORE_URLS=http://+:8080 mcr.microsoft.com/dts/dts-emulator:latest +``` + +### 2. Set the connection string + +```bash +export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" +``` + +### 3. Run the sample + +```bash +dotnet run +``` + +Expected output: + +```text +=== Activity versioning ([DurableTaskVersion]) === + +Scheduling CheckoutWorkflow v1 ... + Result: Workflow v1 -> activity v1 quote: $57 (flat $7 shipping) + +Scheduling CheckoutWorkflow v2 ... + Result: Workflow v2 -> activity v2 quote: $45 ($10 bulk discount + $5 shipping) + +Scheduling CheckoutWorkflow v2 with explicit ShippingQuote v1 override ... + Result: Workflow v2 explicit override -> activity v1 quote: $57 (flat $7 shipping) + +Done! Both versions ran in the same worker process. +Default activity calls inherit the orchestration version, but versioned helpers can explicitly override it. +``` + +### 4. Clean up + +```bash +docker rm -f durabletask-emulator +``` + +## When to use this approach + +Activity versioning is useful when: + +- You need orchestration and activity behavior to evolve together across versions +- You want multiple versions of the same logical activity active simultaneously in one worker +- You want activity routing to follow the orchestration instance version by default, with explicit opt-in overrides when needed + +For deployment-based versioning, see the [WorkerVersioningSample](../WorkerVersioningSample/README.md). For the orchestration-focused version of this pattern, see the [PerOrchestratorVersioningSample](../PerOrchestratorVersioningSample/README.md). diff --git a/samples/PerOrchestratorVersioningSample/PerOrchestratorVersioningSample.csproj b/samples/PerOrchestratorVersioningSample/PerOrchestratorVersioningSample.csproj new file mode 100644 index 000000000..495a9a9fc --- /dev/null +++ b/samples/PerOrchestratorVersioningSample/PerOrchestratorVersioningSample.csproj @@ -0,0 +1,25 @@ + + + + Exe + net8.0;net10.0 + enable + + + + + + + + + + + + + + + + + diff --git a/samples/PerOrchestratorVersioningSample/Program.cs b/samples/PerOrchestratorVersioningSample/Program.cs new file mode 100644 index 000000000..738abd690 --- /dev/null +++ b/samples/PerOrchestratorVersioningSample/Program.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates per-orchestrator versioning with [DurableTaskVersion]. +// Multiple implementations of the same logical orchestration name coexist in one +// worker process. The source generator produces version-qualified helper methods +// that route each instance to the correct implementation automatically. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// Read the DTS connection string from configuration. +string connectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException( + "Set DURABLE_TASK_SCHEDULER_CONNECTION_STRING. " + + "For the local emulator: Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"); + +// Configure the worker. AddAllGeneratedTasks() registers every [DurableTask]-annotated +// class in the project — including both versions of OrderWorkflow and MigratingWorkflow. +builder.Services.AddDurableTaskWorker(wb => +{ + wb.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + wb.UseDurableTaskScheduler(connectionString); +}); + +// Configure the client. +builder.Services.AddDurableTaskClient(cb => cb.UseDurableTaskScheduler(connectionString)); + +IHost host = builder.Build(); +await host.StartAsync(); + +await using DurableTaskClient client = host.Services.GetRequiredService(); + +Console.WriteLine("=== Per-orchestrator versioning ([DurableTaskVersion]) ==="); +Console.WriteLine(); + +// 1) Schedule an OrderWorkflow version 1 instance. +// The generated helper ScheduleNewOrderWorkflow_1InstanceAsync automatically +// stamps the instance with version "1". +Console.WriteLine("Scheduling OrderWorkflow v1 ..."); +string v1Id = await client.ScheduleNewOrderWorkflow_1InstanceAsync(5); +OrchestrationMetadata v1 = await client.WaitForInstanceCompletionAsync(v1Id, getInputsAndOutputs: true); +Console.WriteLine($" Result: {v1.ReadOutputAs()}"); +Console.WriteLine(); + +// 2) Schedule an OrderWorkflow version 2 instance — same logical name, different logic. +Console.WriteLine("Scheduling OrderWorkflow v2 ..."); +string v2Id = await client.ScheduleNewOrderWorkflow_2InstanceAsync(5); +OrchestrationMetadata v2 = await client.WaitForInstanceCompletionAsync(v2Id, getInputsAndOutputs: true); +Console.WriteLine($" Result: {v2.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Done! Both versions ran in the same worker process."); +Console.WriteLine(); + +// 3) Demonstrate ContinueAsNew version migration: the v1 orchestration migrates +// to v2 using ContinueAsNewOptions.NewVersion. This is the safest migration point +// for eternal orchestrations because the history is fully reset. +Console.WriteLine("Scheduling MigratingWorkflow v1 → v2 (ContinueAsNew migration) ..."); +string migrateId = await client.ScheduleNewMigratingWorkflow_1InstanceAsync(new MigrationInput(10)); +OrchestrationMetadata migrate = await client.WaitForInstanceCompletionAsync(migrateId, getInputsAndOutputs: true); +Console.WriteLine($" Result: {migrate.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Sample completed successfully!"); +await host.StopAsync(); + +// ───────────────────────────────────────────────────────────────────────────── +// Orchestrator classes — same logical name, different versions +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// OrderWorkflow v1 — computes the total with no discount. +/// +[DurableTask("OrderWorkflow")] +[DurableTaskVersion("1")] +public sealed class OrderWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int itemCount) + { + int total = itemCount * 10; // $10 per item + return Task.FromResult($"Order total: ${total} (v1 — no discount)"); + } +} + +/// +/// OrderWorkflow v2 — applies a 20% discount to orders of 5+ items. +/// +[DurableTask("OrderWorkflow")] +[DurableTaskVersion("2")] +public sealed class OrderWorkflowV2 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int itemCount) + { + int total = itemCount * 10; + if (itemCount >= 5) + { + total = (int)(total * 0.8); // 20% discount + } + + return Task.FromResult($"Order total: ${total} (v2 — with discount)"); + } +} + +/// +/// MigratingWorkflow v1 — migrates to v2 via ContinueAsNew. +/// The input is an (itemCount, alreadyMigrated) tuple to guard against infinite loops +/// if the backend does not propagate NewVersion. +/// +[DurableTask("MigratingWorkflow")] +[DurableTaskVersion("1")] +public sealed class MigratingWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, MigrationInput input) + { + if (input.AlreadyMigrated) + { + // NewVersion was not propagated — complete here instead of looping. + return Task.FromResult($"Order total: ${input.ItemCount * 10} (v1 — migration not supported by backend)"); + } + + // Migrate to v2. The history is fully reset so there is no replay conflict risk. + context.ContinueAsNew(new ContinueAsNewOptions + { + NewInput = new MigrationInput(input.ItemCount, AlreadyMigrated: true), + NewVersion = "2", + }); + + return Task.FromResult(string.Empty); + } +} + +/// +/// MigratingWorkflow v2 — the target of the v1 → v2 migration. +/// +[DurableTask("MigratingWorkflow")] +[DurableTaskVersion("2")] +public sealed class MigratingWorkflowV2 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, MigrationInput input) + { + int total = input.ItemCount * 10; + if (input.ItemCount >= 5) + { + total = (int)(total * 0.8); + } + + return Task.FromResult($"Migrated order total: ${total} (v2 — after migration from v1)"); + } +} + +/// +/// Input for the MigratingWorkflow orchestrators. +/// +public sealed record MigrationInput(int ItemCount, bool AlreadyMigrated = false); diff --git a/samples/PerOrchestratorVersioningSample/README.md b/samples/PerOrchestratorVersioningSample/README.md new file mode 100644 index 000000000..680ed7d2a --- /dev/null +++ b/samples/PerOrchestratorVersioningSample/README.md @@ -0,0 +1,67 @@ +# Per-Orchestrator Versioning Sample + +This sample demonstrates per-orchestrator versioning with `[DurableTaskVersion]`, where multiple implementations of the same logical orchestration name coexist in one worker process. + +## What it shows + +- Two classes share the same `[DurableTask("OrderWorkflow")]` name but have different `[DurableTaskVersion]` values +- The source generator produces version-qualified helpers like `ScheduleNewOrderWorkflow_1InstanceAsync()` and `ScheduleNewOrderWorkflow_2InstanceAsync()` +- `AddAllGeneratedTasks()` registers both versions automatically +- Each instance is routed to the correct implementation based on its version + +## Prerequisites + +- .NET 8.0 or 10.0 SDK +- [Docker](https://www.docker.com/get-started) + +## Running the Sample + +### 1. Start the DTS emulator + +```bash +docker run --name durabletask-emulator -d -p 8080:8080 -e ASPNETCORE_URLS=http://+:8080 mcr.microsoft.com/dts/dts-emulator:latest +``` + +### 2. Set the connection string + +```bash +export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" +``` + +### 3. Run the sample + +```bash +dotnet run +``` + +Expected output: + +``` +=== Per-orchestrator versioning ([DurableTaskVersion]) === + +Scheduling OrderWorkflow v1 ... + Result: Order total: $50 (v1 — no discount) + +Scheduling OrderWorkflow v2 ... + Result: Order total: $40 (v2 — with discount) + +Done! Both versions ran in the same worker process. +``` + +### 4. Clean up + +```bash +docker rm -f durabletask-emulator +``` + +## When to use this approach + +Per-orchestrator versioning is useful when: + +- You need multiple versions of the same orchestration active simultaneously +- You want version-specific routing without deploying separate workers +- You're building a system where callers choose which version to invoke + +For simpler deployment-based versioning, see the [WorkerVersioningSample](../WorkerVersioningSample/README.md). + +> **Note:** Do not combine `[DurableTaskVersion]` routing with worker-level `UseVersioning()` in the same worker. Both features use the orchestration instance version field. diff --git a/samples/WorkerVersioningSample/Program.cs b/samples/WorkerVersioningSample/Program.cs new file mode 100644 index 000000000..9e4d0eace --- /dev/null +++ b/samples/WorkerVersioningSample/Program.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates worker-level versioning. Each worker deployment is pinned +// to a single version string via UseDefaultVersion(). The client stamps new orchestration +// instances with that version. To upgrade, you deploy a new worker binary with the +// updated implementation. +// +// This sample registers a single orchestration ("GreetingWorkflow") and shows how +// the version is associated with the orchestration instance. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// Read the DTS connection string from configuration. +string connectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException( + "Set DURABLE_TASK_SCHEDULER_CONNECTION_STRING. " + + "For the local emulator: Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"); + +// The worker version represents a deployment version. In production, you'd change this +// when deploying a new version of your worker with updated orchestration logic. +string workerVersion = builder.Configuration.GetValue("WORKER_VERSION") ?? "1.0"; + +// Configure the worker with an orchestration. +builder.Services.AddDurableTaskWorker(wb => +{ + wb.AddTasks(tasks => + { + tasks.AddOrchestratorFunc( + "GreetingWorkflow", + (ctx, name) => Task.FromResult($"Hello, {name}! (worker version: {ctx.Version})")); + }); + + wb.UseDurableTaskScheduler(connectionString); +}); + +// Configure the client. UseDefaultVersion stamps every new orchestration instance +// with this version automatically — no need to set it per-request. +builder.Services.AddDurableTaskClient(cb => +{ + cb.UseDurableTaskScheduler(connectionString); + cb.UseDefaultVersion(workerVersion); +}); + +IHost host = builder.Build(); +await host.StartAsync(); + +await using DurableTaskClient client = host.Services.GetRequiredService(); + +Console.WriteLine($"=== Worker-level versioning (version: {workerVersion}) ==="); +Console.WriteLine(); + +// Schedule a greeting orchestration. The version is automatically stamped by the client. +string instanceId = await client.ScheduleNewOrchestrationInstanceAsync("GreetingWorkflow", "World"); +Console.WriteLine($"Started orchestration: {instanceId}"); + +OrchestrationMetadata result = await client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true); +Console.WriteLine($"Status: {result.RuntimeStatus}"); +Console.WriteLine($"Output: {result.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Try running again with WORKER_VERSION=2.0 to simulate a deployment upgrade."); + +await host.StopAsync(); diff --git a/samples/WorkerVersioningSample/README.md b/samples/WorkerVersioningSample/README.md new file mode 100644 index 000000000..506575c53 --- /dev/null +++ b/samples/WorkerVersioningSample/README.md @@ -0,0 +1,56 @@ +# Worker-Level Versioning Sample + +This sample demonstrates worker-level versioning, where each worker deployment is associated with a single version string. + +## What it shows + +- The client uses `UseDefaultVersion()` to stamp every new orchestration instance with a version +- The orchestration reads `context.Version` to see what version it was scheduled with +- To "upgrade," you redeploy the worker with a new implementation and change the version string + +## Prerequisites + +- .NET 8.0 or 10.0 SDK +- [Docker](https://www.docker.com/get-started) + +## Running the Sample + +### 1. Start the DTS emulator + +```bash +docker run --name durabletask-emulator -d -p 8080:8080 -e ASPNETCORE_URLS=http://+:8080 mcr.microsoft.com/dts/dts-emulator:latest +``` + +### 2. Set the connection string + +```bash +export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" +``` + +### 3. Run with version 1.0 (default) + +```bash +dotnet run +``` + +### 4. Simulate a deployment upgrade to version 2.0 + +```bash +WORKER_VERSION=2.0 dotnet run +``` + +### 5. Clean up + +```bash +docker rm -f durabletask-emulator +``` + +## When to use this approach + +Worker-level versioning is the simplest model. Use it when: + +- You deploy one version of your orchestration logic at a time +- You want a straightforward rolling upgrade story +- You don't need multiple versions of the same orchestration active simultaneously + +For running multiple versions of the same orchestration in one worker, see the [PerOrchestratorVersioningSample](../PerOrchestratorVersioningSample/README.md). diff --git a/samples/WorkerVersioningSample/WorkerVersioningSample.csproj b/samples/WorkerVersioningSample/WorkerVersioningSample.csproj new file mode 100644 index 000000000..e19bb7314 --- /dev/null +++ b/samples/WorkerVersioningSample/WorkerVersioningSample.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0;net10.0 + enable + + + + + + + + + + + + + + + diff --git a/src/Abstractions/Abstractions.csproj b/src/Abstractions/Abstractions.csproj index db8be76ab..5fb379322 100644 --- a/src/Abstractions/Abstractions.csproj +++ b/src/Abstractions/Abstractions.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Abstractions/ActivityVersionKey.cs b/src/Abstractions/ActivityVersionKey.cs new file mode 100644 index 000000000..072f7875e --- /dev/null +++ b/src/Abstractions/ActivityVersionKey.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Represents the logical name and version of a registered activity. +/// +internal readonly struct ActivityVersionKey : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The activity name. + /// The activity version. + public ActivityVersionKey(TaskName name, TaskVersion version) + : this(name.Name, version.Version) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The activity name. + /// The activity version. + public ActivityVersionKey(string name, string? version) + { + this.Name = Check.NotNullOrEmpty(name, nameof(name)); + this.Version = version ?? string.Empty; + } + + /// + /// Gets the logical activity name. + /// + public string Name { get; } + + /// + /// Gets the activity version. + /// + public string Version { get; } + + /// + /// Determines whether the specified key is equal to the current key. + /// + /// The key to compare with the current key. + /// true if the keys are equal; otherwise false. + public bool Equals(ActivityVersionKey other) + { + return string.Equals(this.Name, other.Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(this.Version, other.Version, StringComparison.OrdinalIgnoreCase); + } + + /// + public override bool Equals(object? obj) => obj is ActivityVersionKey other && this.Equals(other); + + /// + public override int GetHashCode() + { + unchecked + { + return (StringComparer.OrdinalIgnoreCase.GetHashCode(this.Name) * 397) + ^ StringComparer.OrdinalIgnoreCase.GetHashCode(this.Version); + } + } +} diff --git a/src/Abstractions/DurableTaskRegistry.Activities.cs b/src/Abstractions/DurableTaskRegistry.Activities.cs index ac525147a..f4c4e898e 100644 --- a/src/Abstractions/DurableTaskRegistry.Activities.cs +++ b/src/Abstractions/DurableTaskRegistry.Activities.cs @@ -32,6 +32,27 @@ TaskName ITaskActivity singleton Action{Context} */ + /// + /// Registers an activity factory. + /// + /// The name of the activity. + /// The activity version. + /// The activity factory. + /// This registry instance, for call chaining. + /// + /// Thrown if any of the following are true: + /// + /// If is default. + /// If and are already registered. + /// If is null. + /// + /// + public DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func factory) + { + Check.NotNull(factory); + return this.AddActivity(name, version, _ => factory()); + } + /// /// Registers an activity factory, resolving the provided type with the service provider. /// @@ -41,7 +62,10 @@ TaskName ITaskActivity singleton public DurableTaskRegistry AddActivity(TaskName name, Type type) { Check.ConcreteType(type); - return this.AddActivity(name, sp => (ITaskActivity)ActivatorUtilities.GetServiceOrCreateInstance(sp, type)); + return this.AddActivity( + name, + type.GetDurableTaskVersion(), + sp => (ITaskActivity)ActivatorUtilities.GetServiceOrCreateInstance(sp, type)); } /// @@ -51,7 +75,13 @@ public DurableTaskRegistry AddActivity(TaskName name, Type type) /// The activity type. /// The same registry, for call chaining. public DurableTaskRegistry AddActivity(Type type) - => this.AddActivity(type.GetTaskName(), type); + { + Check.ConcreteType(type); + return this.AddActivity( + type.GetTaskName(), + type.GetDurableTaskVersion(), + sp => (ITaskActivity)ActivatorUtilities.GetServiceOrCreateInstance(sp, type)); + } /// /// Registers an activity factory, resolving the provided type with the service provider. @@ -77,23 +107,26 @@ public DurableTaskRegistry AddActivity() /// Registers an activity singleton. /// /// The name of the activity to register. - /// The orchestration instance to use. + /// The activity instance to use. /// The same registry, for call chaining. public DurableTaskRegistry AddActivity(TaskName name, ITaskActivity activity) { Check.NotNull(activity); - return this.AddActivity(name, (IServiceProvider _) => activity); + return this.AddActivity(name, activity.GetType().GetDurableTaskVersion(), () => activity); } /// /// Registers an activity singleton. /// - /// The orchestration instance to use. + /// The activity instance to use. /// The same registry, for call chaining. public DurableTaskRegistry AddActivity(ITaskActivity activity) { Check.NotNull(activity); - return this.AddActivity(activity.GetType().GetTaskName(), activity); + return this.AddActivity( + activity.GetType().GetTaskName(), + activity.GetType().GetDurableTaskVersion(), + () => activity); } /// diff --git a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs index 7ad7583f0..80543040a 100644 --- a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs +++ b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs @@ -30,6 +30,60 @@ TaskName and ITaskOrchestrator singleton Action{Context} */ + /// + /// Registers an orchestrator factory. + /// + /// The name of the orchestrator. + /// The orchestrator version. + /// The orchestrator factory. + /// This registry instance, for call chaining. + /// + /// Thrown if any of the following are true: + /// + /// If is default. + /// If and are already registered. + /// If is null. + /// + /// + /// + /// Registration is version-aware in the registry. Worker dispatch uses exact and + /// matching, while the public name-only factory path continues to resolve only the + /// default registration. + /// + public DurableTaskRegistry AddOrchestrator(TaskName name, TaskVersion version, Func factory) + { + Check.NotDefault(name); + Check.NotNull(factory); + + OrchestratorVersionKey key = new(name, version); + if (this.Orchestrators.ContainsKey(key)) + { + throw new ArgumentException( + $"An {nameof(ITaskOrchestrator)} named '{name}' with version '{version.Version ?? string.Empty}' is already added.", + nameof(name)); + } + + this.Orchestrators.Add(key, _ => factory()); + return this; + } + + /// + /// Registers an orchestrator factory. + /// + /// The name of the orchestrator. + /// The orchestrator factory. + /// This registry instance, for call chaining. + /// + /// Thrown if any of the following are true: + /// + /// If is default. + /// If is already registered. + /// If is null. + /// + /// + public DurableTaskRegistry AddOrchestrator(TaskName name, Func factory) + => this.AddOrchestrator(name, default, factory); + /// /// Registers an orchestrator factory. /// @@ -40,7 +94,10 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, Type type) { // TODO: Compile a constructor expression for performance. Check.ConcreteType(type); - return this.AddOrchestrator(name, () => (ITaskOrchestrator)Activator.CreateInstance(type)); + return this.AddOrchestrator( + name, + type.GetDurableTaskVersion(), + () => (ITaskOrchestrator)Activator.CreateInstance(type)); } /// @@ -49,7 +106,10 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, Type type) /// The orchestrator type. /// The same registry, for call chaining. public DurableTaskRegistry AddOrchestrator(Type type) - => this.AddOrchestrator(type.GetTaskName(), type); + { + Check.ConcreteType(type); + return this.AddOrchestrator(type.GetTaskName(), type.GetDurableTaskVersion(), () => (ITaskOrchestrator)Activator.CreateInstance(type)); + } /// /// Registers an orchestrator factory. @@ -79,7 +139,7 @@ public DurableTaskRegistry AddOrchestrator() public DurableTaskRegistry AddOrchestrator(TaskName name, ITaskOrchestrator orchestrator) { Check.NotNull(orchestrator); - return this.AddOrchestrator(name, () => orchestrator); + return this.AddOrchestrator(name, orchestrator.GetType().GetDurableTaskVersion(), () => orchestrator); } /// @@ -90,7 +150,10 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, ITaskOrchestrator orch public DurableTaskRegistry AddOrchestrator(ITaskOrchestrator orchestrator) { Check.NotNull(orchestrator); - return this.AddOrchestrator(orchestrator.GetType().GetTaskName(), orchestrator); + return this.AddOrchestrator( + orchestrator.GetType().GetTaskName(), + orchestrator.GetType().GetDurableTaskVersion(), + () => orchestrator); } /// diff --git a/src/Abstractions/DurableTaskRegistry.cs b/src/Abstractions/DurableTaskRegistry.cs index 03e21c450..7937618bb 100644 --- a/src/Abstractions/DurableTaskRegistry.cs +++ b/src/Abstractions/DurableTaskRegistry.cs @@ -16,14 +16,14 @@ public sealed partial class DurableTaskRegistry /// /// Gets the currently registered activities. /// - internal IDictionary> Activities { get; } - = new Dictionary>(); + internal IDictionary> Activities { get; } + = new Dictionary>(); /// /// Gets the currently registered orchestrators. /// - internal IDictionary> Orchestrators { get; } - = new Dictionary>(); + internal IDictionary> Orchestrators { get; } + = new Dictionary>(); /// /// Gets the currently registered entities. @@ -46,23 +46,13 @@ public sealed partial class DurableTaskRegistry /// /// public DurableTaskRegistry AddActivity(TaskName name, Func factory) - { - Check.NotDefault(name); - Check.NotNull(factory); - if (this.Activities.ContainsKey(name)) - { - throw new ArgumentException($"An {nameof(ITaskActivity)} named '{name}' is already added.", nameof(name)); - } - - this.Activities.Add(name, factory); - return this; - } + => this.AddActivity(name, default, factory); /// - /// Registers an orchestrator factory. + /// Registers an entity factory. /// - /// The name of the orchestrator. - /// The orchestrator factory. + /// The name of the entity. + /// The entity factory. /// This registry instance, for call chaining. /// /// Thrown if any of the following are true: @@ -72,44 +62,34 @@ public DurableTaskRegistry AddActivity(TaskName name, FuncIf is null. /// /// - public DurableTaskRegistry AddOrchestrator(TaskName name, Func factory) + public DurableTaskRegistry AddEntity(TaskName name, Func factory) { Check.NotDefault(name); Check.NotNull(factory); - if (this.Orchestrators.ContainsKey(name)) + if (this.Entities.ContainsKey(name)) { - throw new ArgumentException( - $"An {nameof(ITaskOrchestrator)} named '{name}' is already added.", nameof(name)); + throw new ArgumentException($"An {nameof(ITaskEntity)} named '{name}' is already added.", nameof(name)); } - this.Orchestrators.Add(name, _ => factory()); + this.Entities.Add(name, factory); return this; } - /// - /// Registers an entity factory. - /// - /// The name of the entity. - /// The entity factory. - /// This registry instance, for call chaining. - /// - /// Thrown if any of the following are true: - /// - /// If is default. - /// If is already registered. - /// If is null. - /// - /// - public DurableTaskRegistry AddEntity(TaskName name, Func factory) + DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func factory) { Check.NotDefault(name); Check.NotNull(factory); - if (this.Entities.ContainsKey(name)) + + ActivityVersionKey key = new(name, version); + if (this.Activities.ContainsKey(key)) { - throw new ArgumentException($"An {nameof(ITaskEntity)} named '{name}' is already added.", nameof(name)); + string message = string.IsNullOrEmpty(version.Version) + ? $"An {nameof(ITaskActivity)} named '{name}' is already added." + : $"An {nameof(ITaskActivity)} named '{name}' with version '{version.Version}' is already added."; + throw new ArgumentException(message, nameof(name)); } - this.Entities.Add(name, factory); + this.Activities.Add(key, factory); return this; } } diff --git a/src/Abstractions/DurableTaskVersionAttribute.cs b/src/Abstractions/DurableTaskVersionAttribute.cs new file mode 100644 index 000000000..a8c9108b8 --- /dev/null +++ b/src/Abstractions/DurableTaskVersionAttribute.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Indicates the version of a class-based durable orchestrator or activity. +/// +/// +/// This attribute is consumed for orchestrator and activity registrations and source generation where applicable. +/// Entities ignore this attribute. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class DurableTaskVersionAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The version string for the orchestrator or activity. + public DurableTaskVersionAttribute(string? version = null) + { + this.Version = string.IsNullOrEmpty(version) ? default : new TaskVersion(version!); + } + + /// + /// Gets the durable task version declared on the attributed class. + /// + public TaskVersion Version { get; } +} diff --git a/src/Abstractions/OrchestratorVersionKey.cs b/src/Abstractions/OrchestratorVersionKey.cs new file mode 100644 index 000000000..30c2a62ba --- /dev/null +++ b/src/Abstractions/OrchestratorVersionKey.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Represents the logical name and version of a registered orchestrator. +/// +internal readonly struct OrchestratorVersionKey : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The orchestrator name. + /// The orchestrator version. + public OrchestratorVersionKey(TaskName name, TaskVersion version) + : this(name.Name, version.Version) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The orchestrator name. + /// The orchestrator version. + public OrchestratorVersionKey(string name, string? version) + { + this.Name = Check.NotNullOrEmpty(name, nameof(name)); + this.Version = version ?? string.Empty; + } + + /// + /// Gets the logical orchestrator name. + /// + public string Name { get; } + + /// + /// Gets the orchestrator version. + /// + public string Version { get; } + + /// + /// Determines whether the specified key is equal to the current key. + /// + /// The key to compare with the current key. + /// true if the keys are equal; otherwise false. + public bool Equals(OrchestratorVersionKey other) + { + return string.Equals(this.Name, other.Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(this.Version, other.Version, StringComparison.OrdinalIgnoreCase); + } + + /// + public override bool Equals(object? obj) => obj is OrchestratorVersionKey other && this.Equals(other); + + /// + public override int GetHashCode() + { + unchecked + { + return (StringComparer.OrdinalIgnoreCase.GetHashCode(this.Name) * 397) + ^ StringComparer.OrdinalIgnoreCase.GetHashCode(this.Version); + } + } +} diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index 7c0d54ee2..419b67c16 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -8,8 +8,8 @@ namespace Microsoft.DurableTask; /// /// Options that can be used to control the behavior of orchestrator task execution. /// -public record TaskOptions -{ +public record TaskOptions +{ /// /// Initializes a new instance of the class. /// @@ -77,15 +77,60 @@ public TaskOptions(TaskOptions options) /// starting a new sub-orchestration to specify the instance ID. /// /// The instance ID to use. - /// A new . - public SubOrchestrationOptions WithInstanceId(string instanceId) => new(this, instanceId); -} - -/// -/// Options that can be used to control the behavior of orchestrator task execution. This derived type can be used to -/// supply extra options for orchestrations. -/// -public record SubOrchestrationOptions : TaskOptions + /// A new . + public SubOrchestrationOptions WithInstanceId(string instanceId) => new(this, instanceId); +} + +/// +/// Options that can be used to control the behavior of activity task execution. This derived type can be used to +/// supply extra options for activities. +/// +public record ActivityOptions : TaskOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The task retry options. + public ActivityOptions(TaskRetryOptions? retry = null) + : base(retry) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The task options to wrap. + public ActivityOptions(TaskOptions options) + : base(options) + { + if (options is ActivityOptions derived) + { + this.Version = derived.Version; + } + } + + /// + /// Initializes a new instance of the class by copying from another instance. + /// + /// The activity options to copy from. + public ActivityOptions(ActivityOptions options) + : base(options) + { + Check.NotNull(options); + this.Version = options.Version; + } + + /// + /// Gets the version to associate with the activity. + /// + public TaskVersion? Version { get; init; } +} + +/// +/// Options that can be used to control the behavior of orchestrator task execution. This derived type can be used to +/// supply extra options for orchestrations. +/// +public record SubOrchestrationOptions : TaskOptions { /// /// Initializes a new instance of the class. diff --git a/src/Abstractions/TypeExtensions.cs b/src/Abstractions/TypeExtensions.cs index 0a4ae181b..06b43020b 100644 --- a/src/Abstractions/TypeExtensions.cs +++ b/src/Abstractions/TypeExtensions.cs @@ -15,7 +15,7 @@ static class TypeExtensions /// The task name. public static TaskName GetTaskName(this Type type) { - // IMPORTANT: This logic needs to be kept consistent with the source generator logic + // IMPORTANT: This logic needs to be kept consistent with the source generator logic. Check.NotNull(type); return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) switch { @@ -23,4 +23,20 @@ public static TaskName GetTaskName(this Type type) _ => new TaskName(type.Name), }; } + + /// + /// Gets the durable task version for a type. + /// + /// The type to get the durable task version for. + /// The durable task version. + internal static TaskVersion GetDurableTaskVersion(this Type type) + { + // IMPORTANT: This logic needs to be kept consistent with the source generator logic. + Check.NotNull(type); + return Attribute.GetCustomAttribute(type, typeof(DurableTaskVersionAttribute)) switch + { + DurableTaskVersionAttribute { Version.Version: not null and not "" } attr => attr.Version, + _ => default, + }; + } } diff --git a/src/Generators/AnalyzerReleases.Unshipped.md b/src/Generators/AnalyzerReleases.Unshipped.md index bee547b6d..5e333b839 100644 --- a/src/Generators/AnalyzerReleases.Unshipped.md +++ b/src/Generators/AnalyzerReleases.Unshipped.md @@ -7,3 +7,5 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- DURABLE3001 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when a task name in [DurableTask] attribute is not a valid C# identifier. Task names must start with a letter or underscore and contain only letters, digits, and underscores. DURABLE3002 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when an event name in [DurableEvent] attribute is not a valid C# identifier. Event names must start with a letter or underscore and contain only letters, digits, and underscores. +DURABLE3003 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when a standalone project declares the same orchestrator or activity logical name and version more than once. +DURABLE3004 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when an Azure Functions project declares multiple class-based orchestrators or activities with the same logical durable task name. diff --git a/src/Generators/AzureFunctions/SyntaxNodeUtility.cs b/src/Generators/AzureFunctions/SyntaxNodeUtility.cs index e5bbdf3f5..8bd7a2456 100644 --- a/src/Generators/AzureFunctions/SyntaxNodeUtility.cs +++ b/src/Generators/AzureFunctions/SyntaxNodeUtility.cs @@ -131,7 +131,7 @@ public static bool TryGetParameter( { string attributeName = attribute.Name.ToString(); if ((kind == DurableFunctionKind.Activity && attributeName == "ActivityTrigger") || - (kind == DurableFunctionKind.Orchestration && attributeName == "OrchestratorTrigger") || + (kind == DurableFunctionKind.Orchestration && attributeName == "OrchestrationTrigger") || (kind == DurableFunctionKind.Entity && attributeName == "EntityTrigger")) { TypeInfo info = model.GetTypeInfo(methodParam.Type); diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index b4b2de97c..dc6a25f0e 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; +using System.Globalization; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; @@ -50,6 +52,16 @@ public class DurableTaskSourceGenerator : IIncrementalGenerator /// const string InvalidEventNameDiagnosticId = "DURABLE3002"; + /// + /// Diagnostic ID for duplicate standalone orchestrator or activity logical name + version combinations. + /// + const string DuplicateStandaloneOrchestratorVersionDiagnosticId = "DURABLE3003"; + + /// + /// Diagnostic ID for Azure Functions orchestrator or activity logical name collisions. + /// + const string DuplicateAzureFunctionsOrchestratorNameDiagnosticId = "DURABLE3004"; + static readonly DiagnosticDescriptor InvalidTaskNameRule = new( InvalidTaskNameDiagnosticId, title: "Invalid task name", @@ -66,6 +78,22 @@ public class DurableTaskSourceGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + static readonly DiagnosticDescriptor DuplicateStandaloneOrchestratorVersionRule = new( + DuplicateStandaloneOrchestratorVersionDiagnosticId, + title: "Duplicate standalone durable task logical name and version", + messageFormat: "The standalone durable task logical name '{0}' with version '{1}' is declared more than once. Each logical name and version combination must be unique.", + category: "DurableTask.Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + static readonly DiagnosticDescriptor DuplicateAzureFunctionsOrchestratorNameRule = new( + DuplicateAzureFunctionsOrchestratorNameDiagnosticId, + title: "Azure Functions multi-version class-based tasks are not supported", + messageFormat: "Azure Functions projects cannot generate multiple class-based orchestrators or activities with the durable task name '{0}'. Use the standalone worker or keep a single logical task per name.", + category: "DurableTask.Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + /// public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -214,7 +242,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) taskNameLocation = expression.GetLocation(); } - return new DurableTaskTypeInfo(className, classNamespace, taskName, inputType, outputType, kind, taskNameLocation); + string taskVersion = string.Empty; + foreach (AttributeData attributeData in classType.GetAttributes()) + { + if (attributeData.AttributeClass?.ToDisplayString() == "Microsoft.DurableTask.DurableTaskVersionAttribute" + && attributeData.ConstructorArguments.Length > 0 + && attributeData.ConstructorArguments[0].Value is string version) + { + taskVersion = version; + break; + } + } + + return new DurableTaskTypeInfo(className, classNamespace, taskName, inputType, outputType, kind, taskVersion, taskNameLocation); } static DurableEventTypeInfo? GetDurableEventTypeInfo(GeneratorSyntaxContext context) @@ -338,10 +378,30 @@ static void Execute( IEnumerable validTasks = allTasks .Where(task => IsValidCSharpIdentifier(task.TaskName)); + Dictionary standaloneOrchestratorRegistrations = new(StringComparer.OrdinalIgnoreCase); + Dictionary standaloneActivityRegistrations = new(StringComparer.OrdinalIgnoreCase); foreach (DurableTaskTypeInfo task in validTasks) { if (task.IsActivity) { + if (!isDurableFunctions) + { + string registrationKey = GetStandaloneTaskRegistrationKey(task.TaskName, task.TaskVersion); + if (standaloneActivityRegistrations.ContainsKey(registrationKey)) + { + Location location = task.TaskNameLocation ?? Location.None; + Diagnostic diagnostic = Diagnostic.Create( + DuplicateStandaloneOrchestratorVersionRule, + location, + task.TaskName, + task.TaskVersion); + context.ReportDiagnostic(diagnostic); + continue; + } + + standaloneActivityRegistrations.Add(registrationKey, task); + } + activities.Add(task); } else if (task.IsEntity) @@ -350,10 +410,97 @@ static void Execute( } else { + if (!isDurableFunctions) + { + string registrationKey = GetStandaloneTaskRegistrationKey(task.TaskName, task.TaskVersion); + if (standaloneOrchestratorRegistrations.ContainsKey(registrationKey)) + { + Location location = task.TaskNameLocation ?? Location.None; + Diagnostic diagnostic = Diagnostic.Create( + DuplicateStandaloneOrchestratorVersionRule, + location, + task.TaskName, + task.TaskVersion); + context.ReportDiagnostic(diagnostic); + continue; + } + + standaloneOrchestratorRegistrations.Add(registrationKey, task); + } + orchestrators.Add(task); } } + if (isDurableFunctions) + { + HashSet existingAzureFunctionsOrchestratorNames = new( + allFunctions + .Where(function => function.Kind == DurableFunctionKind.Orchestration) + .Select(function => function.Name), + StringComparer.OrdinalIgnoreCase); + + HashSet collidingAzureFunctionsOrchestrators = new( + orchestrators + .Where(task => existingAzureFunctionsOrchestratorNames.Contains(task.TaskName)) + .Concat( + orchestrators + .GroupBy(task => task.TaskName, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .SelectMany(group => group))); + + foreach (DurableTaskTypeInfo task in collidingAzureFunctionsOrchestrators) + { + Location location = task.TaskNameLocation ?? Location.None; + Diagnostic diagnostic = Diagnostic.Create( + DuplicateAzureFunctionsOrchestratorNameRule, + location, + task.TaskName); + context.ReportDiagnostic(diagnostic); + } + + orchestrators = orchestrators + .Where(task => !collidingAzureFunctionsOrchestrators.Contains(task)) + .ToList(); + + HashSet existingAzureFunctionsActivityNames = new( + allFunctions + .Where(function => function.Kind == DurableFunctionKind.Activity) + .Select(function => function.Name), + StringComparer.OrdinalIgnoreCase); + + HashSet collidingAzureFunctionsActivities = new( + activities + .Where(task => existingAzureFunctionsActivityNames.Contains(task.TaskName)) + .Concat( + activities + .GroupBy(task => task.TaskName, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .SelectMany(group => group))); + + foreach (DurableTaskTypeInfo task in collidingAzureFunctionsActivities) + { + Location location = task.TaskNameLocation ?? Location.None; + Diagnostic diagnostic = Diagnostic.Create( + DuplicateAzureFunctionsOrchestratorNameRule, + location, + task.TaskName); + context.ReportDiagnostic(diagnostic); + } + + activities = activities + .Where(task => !collidingAzureFunctionsActivities.Contains(task)) + .ToList(); + } + + Dictionary standaloneOrchestratorCountsByTaskName = orchestrators + .GroupBy(task => task.TaskName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); + + Dictionary standaloneActivityCountsByTaskName = activities + .GroupBy(task => task.TaskName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); + // Filter out events with invalid names List validEvents = allEvents .Where(eventInfo => IsValidCSharpIdentifier(eventInfo.EventName)) @@ -455,6 +602,11 @@ static void Execute( bool hasActivityTriggers = isMicrosoftDurableTask && activityTriggers.Count > 0; bool hasEvents = eventsInNamespace != null && eventsInNamespace.Count > 0; bool hasRegistration = isMicrosoftDurableTask && needsRegistrationMethod; + bool hasVersionedStandaloneOrchestratorHelpers = !isDurableFunctions + && orchestratorsInNs.Any(task => !string.IsNullOrEmpty(task.TaskVersion)); + bool hasVersionedStandaloneActivityHelpers = !isDurableFunctions + && activitiesInNs.Any(task => !string.IsNullOrEmpty(task.TaskVersion)); + bool hasVersionedStandaloneHelpers = hasVersionedStandaloneOrchestratorHelpers || hasVersionedStandaloneActivityHelpers; if (!hasOrchestratorMethods && !hasActivityMethods && !hasEntityFunctions && !hasActivityTriggers && !hasEvents && !hasRegistration) @@ -485,13 +637,17 @@ public static class GeneratedDurableTaskExtensions AddOrchestratorFunctionDeclaration(sourceBuilder, orchestrator, targetNamespace); } - AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace); - AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace); + string helperSuffix = GetStandaloneTaskHelperSuffix(orchestrator, isDurableFunctions, standaloneOrchestratorCountsByTaskName); + bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(orchestrator.TaskVersion); + AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperSuffix, applyGeneratedVersion); + AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperSuffix, applyGeneratedVersion); } foreach (DurableTaskTypeInfo activity in activitiesInNs) { - AddActivityCallMethod(sourceBuilder, activity, targetNamespace); + string helperSuffix = GetStandaloneTaskHelperSuffix(activity, isDurableFunctions, standaloneActivityCountsByTaskName); + bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(activity.TaskVersion); + AddActivityCallMethod(sourceBuilder, activity, targetNamespace, helperSuffix, applyGeneratedVersion); if (isDurableFunctions) { @@ -499,6 +655,14 @@ public static class GeneratedDurableTaskExtensions } } + if (hasVersionedStandaloneHelpers) + { + AddStandaloneGeneratedVersionHelperMethods( + sourceBuilder, + hasVersionedStandaloneOrchestratorHelpers, + hasVersionedStandaloneActivityHelpers); + } + foreach (DurableTaskTypeInfo entity in entitiesInNs) { if (isDurableFunctions) @@ -612,6 +776,50 @@ static string SimplifyTypeName(string fullyQualifiedTypeName, string targetNames return fullyQualifiedTypeName; } + static string GetStandaloneTaskHelperSuffix(DurableTaskTypeInfo task, bool isDurableFunctions, Dictionary standaloneTaskCountsByTaskName) + { + if (isDurableFunctions + || string.IsNullOrEmpty(task.TaskVersion) + || !standaloneTaskCountsByTaskName.TryGetValue(task.TaskName, out int count) + || count <= 1) + { + return string.Empty; + } + + return ToVersionSuffix(task.TaskVersion); + } + + static string GetStandaloneTaskRegistrationKey(string taskName, string taskVersion) + { + return string.Concat(taskName, "\0", taskVersion); + } + + static string ToVersionSuffix(string version) + { + if (string.IsNullOrEmpty(version)) + { + return string.Empty; + } + + StringBuilder suffixBuilder = new(version.Length + 1); + suffixBuilder.Append('_'); + foreach (char c in version) + { + if (char.IsLetterOrDigit(c) || c == '_') + { + suffixBuilder.Append(c); + } + else + { + suffixBuilder.Append("_x").Append(((int)c).ToString("X4", CultureInfo.InvariantCulture)).Append('_'); + } + } + + return suffixBuilder.ToString(); + } + + static string ToCSharpStringLiteral(string value) => SymbolDisplay.FormatLiteral(value, quote: true); + static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); @@ -626,7 +834,7 @@ static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, Dura }}"); } - static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) + static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace, string helperSuffix, bool applyGeneratedVersion) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); string inputParameter = inputType + " input"; @@ -636,20 +844,23 @@ static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTy } string simplifiedTypeName = SimplifyTypeName(orchestrator.TypeName, targetNamespace); + string optionsExpression = applyGeneratedVersion + ? $"ApplyGeneratedVersion(options, {ToCSharpStringLiteral(orchestrator.TaskVersion)})" + : "options"; sourceBuilder.AppendLine($@" /// /// Schedules a new instance of the orchestrator. /// /// - public static Task ScheduleNew{orchestrator.TaskName}InstanceAsync( + public static Task ScheduleNew{orchestrator.TaskName}{helperSuffix}InstanceAsync( this IOrchestrationSubmitter client, {inputParameter}, StartOrchestrationOptions? options = null) {{ - return client.ScheduleNewOrchestrationInstanceAsync(""{orchestrator.TaskName}"", input, options); + return client.ScheduleNewOrchestrationInstanceAsync(""{orchestrator.TaskName}"", input, {optionsExpression}); }}"); } - static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) + static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace, string helperSuffix, bool applyGeneratedVersion) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); string outputType = orchestrator.GetOutputTypeForNamespace(targetNamespace); @@ -660,20 +871,118 @@ static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTas } string simplifiedTypeName = SimplifyTypeName(orchestrator.TypeName, targetNamespace); + string optionsExpression = applyGeneratedVersion + ? $"ApplyGeneratedVersion(options, {ToCSharpStringLiteral(orchestrator.TaskVersion)})" + : "options"; sourceBuilder.AppendLine($@" /// /// Calls the sub-orchestrator. /// /// - public static Task<{outputType}> Call{orchestrator.TaskName}Async( + public static Task<{outputType}> Call{orchestrator.TaskName}{helperSuffix}Async( this TaskOrchestrationContext context, {inputParameter}, TaskOptions? options = null) {{ - return context.CallSubOrchestratorAsync<{outputType}>(""{orchestrator.TaskName}"", input, options); + return context.CallSubOrchestratorAsync<{outputType}>(""{orchestrator.TaskName}"", input, {optionsExpression}); }}"); } - static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace) + static void AddStandaloneGeneratedVersionHelperMethods( + StringBuilder sourceBuilder, + bool includeOrchestrationVersionHelpers, + bool includeActivityVersionHelpers) + { + if (includeOrchestrationVersionHelpers) + { + sourceBuilder.AppendLine(@" + static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) + { + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; + } + + static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) + { + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; + }"); + } + + if (includeActivityVersionHelpers) + { + sourceBuilder.AppendLine(@" + static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) + { + if (options is ActivityOptions activityOptions + && activityOptions.Version is TaskVersion explicitVersion + && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + { + return options; + } + + if (options is ActivityOptions existingActivityOptions) + { + return new ActivityOptions(existingActivityOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new ActivityOptions + { + Version = version, + }; + } + + return new ActivityOptions(options) + { + Version = version, + }; + }"); + } + } + + static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace, string helperSuffix, bool applyGeneratedVersion) { string inputType = activity.GetInputTypeForNamespace(targetNamespace); string outputType = activity.GetOutputTypeForNamespace(targetNamespace); @@ -684,15 +993,18 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeIn } string simplifiedTypeName = SimplifyTypeName(activity.TypeName, targetNamespace); + string optionsExpression = applyGeneratedVersion + ? $"ApplyGeneratedActivityVersion(options, {ToCSharpStringLiteral(activity.TaskVersion)})" + : "options"; sourceBuilder.AppendLine($@" /// /// Calls the activity. /// /// - public static Task<{outputType}> Call{activity.TaskName}Async(this TaskOrchestrationContext ctx, {inputParameter}, TaskOptions? options = null) + public static Task<{outputType}> Call{activity.TaskName}{helperSuffix}Async(this TaskOrchestrationContext ctx, {inputParameter}, TaskOptions? options = null) {{ - return ctx.CallActivityAsync<{outputType}>(""{activity.TaskName}"", input, options); + return ctx.CallActivityAsync<{outputType}>(""{activity.TaskName}"", input, {optionsExpression}); }}"); } @@ -868,12 +1180,14 @@ public DurableTaskTypeInfo( ITypeSymbol? inputType, ITypeSymbol? outputType, DurableTaskKind kind, + string taskVersion, Location? taskNameLocation = null) { this.TypeName = taskType; this.Namespace = taskNamespace; this.TaskName = taskName; this.Kind = kind; + this.TaskVersion = taskVersion; this.TaskNameLocation = taskNameLocation; this.InputTypeSymbol = inputType; this.OutputTypeSymbol = outputType; @@ -882,6 +1196,7 @@ public DurableTaskTypeInfo( public string TypeName { get; } public string Namespace { get; } public string TaskName { get; } + public string TaskVersion { get; } public DurableTaskKind Kind { get; } public Location? TaskNameLocation { get; } ITypeSymbol? InputTypeSymbol { get; } diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto index 0c34d986d..022236290 100644 --- a/src/Grpc/orchestrator_service.proto +++ b/src/Grpc/orchestrator_service.proto @@ -25,6 +25,7 @@ message ActivityRequest { OrchestrationInstance orchestrationInstance = 4; int32 taskId = 5; TraceContext parentTraceContext = 6; + map tags = 7; } message ActivityResponse { @@ -320,6 +321,10 @@ message SendEntityMessageAction { } } +message RewindOrchestrationAction { + repeated HistoryEvent newHistory = 1; +} + message OrchestratorAction { int32 id = 1; oneof orchestratorActionType { @@ -330,6 +335,7 @@ message OrchestratorAction { CompleteOrchestrationAction completeOrchestration = 6; TerminateOrchestrationAction terminateOrchestration = 7; SendEntityMessageAction sendEntityMessage = 8; + RewindOrchestrationAction rewindOrchestration = 9; } } diff --git a/src/Grpc/versions.txt b/src/Grpc/versions.txt index 743f3f8bd..47a19588e 100644 --- a/src/Grpc/versions.txt +++ b/src/Grpc/versions.txt @@ -1,2 +1,2 @@ -# The following files were downloaded from branch main at 2026-02-24 00:01:28 UTC -https://raw.githubusercontent.com/microsoft/durabletask-protobuf/1caadbd7ecfdf5f2309acbeac28a3e36d16aa156/protos/orchestrator_service.proto +# The following files were downloaded from branch main at 2026-04-02 16:51:35 UTC +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/e0ee825d632af47b1e754d98cf15b86e4d6a2e9b/protos/orchestrator_service.proto diff --git a/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs b/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs index 3dfe627b4..bea00fd47 100644 --- a/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs +++ b/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs @@ -284,6 +284,11 @@ void ApplyOrchestratorActions( scheduleTaskAction.Version, scheduleTaskAction.Input); + if (scheduleTaskAction.Tags is not null) + { + scheduledEvent.Tags = new Dictionary(scheduleTaskAction.Tags, StringComparer.Ordinal); + } + if (action is GrpcScheduleTaskOrchestratorAction { ParentTraceContext: not null } grpcAction) { scheduledEvent.ParentTraceContext ??= new(grpcAction.ParentTraceContext.TraceParent, grpcAction.ParentTraceContext.TraceState); diff --git a/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs b/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs index 8289574b6..eeae533cc 100644 --- a/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs +++ b/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs @@ -133,6 +133,12 @@ public static Proto.HistoryEvent ToHistoryEventProto(HistoryEvent e) TraceState = taskScheduledEvent.ParentTraceContext.TraceState, }, }; + + if (taskScheduledEvent.Tags is not null) + { + payload.TaskScheduled.Tags.Add(taskScheduledEvent.Tags); + } + break; case EventType.TaskCompleted: var taskCompletedEvent = (TaskCompletedEvent)e; @@ -273,6 +279,7 @@ public static OrchestratorAction ToOrchestratorAction(Proto.OrchestratorAction a Id = a.Id, Input = a.ScheduleTask.Input, Name = a.ScheduleTask.Name, + Tags = a.ScheduleTask.Tags, Version = a.ScheduleTask.Version, ParentTraceContext = a.ScheduleTask.ParentTraceContext is not null ? new DistributedTraceContext(a.ScheduleTask.ParentTraceContext.TraceParent, a.ScheduleTask.ParentTraceContext.TraceState) diff --git a/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs b/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs index 50f1fe155..016baef4e 100644 --- a/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs +++ b/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs @@ -855,7 +855,7 @@ async Task ITaskExecutor.ExecuteActivity(OrchestrationI try { - await this.SendWorkItemToClientAsync(new P.WorkItem + P.WorkItem workItem = new() { ActivityRequest = new P.ActivityRequest { @@ -876,7 +876,14 @@ await this.SendWorkItemToClientAsync(new P.WorkItem } : null, }, - }); + }; + + if (activityEvent.Tags is not null) + { + workItem.ActivityRequest.Tags.Add(activityEvent.Tags); + } + + await this.SendWorkItemToClientAsync(workItem); } catch { diff --git a/src/Worker/Core/ActivityVersioning.cs b/src/Worker/Core/ActivityVersioning.cs new file mode 100644 index 000000000..354bb8cdb --- /dev/null +++ b/src/Worker/Core/ActivityVersioning.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker; + +/// +/// Internal helpers for preserving activity version-selection semantics across worker dispatch. +/// +static class ActivityVersioning +{ + /// + /// Internal tag stamped on scheduled activity events when the caller explicitly chooses an activity version. + /// + internal const string ExplicitVersionTagName = "microsoft.durabletask.activity.explicit-version"; +} diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 0e77a584a..8216f9752 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -9,10 +9,10 @@ namespace Microsoft.DurableTask.Worker; /// /// A factory for creating orchestrators and activities. /// -sealed class DurableTaskFactory : IDurableTaskFactory2 +sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedActivityFactory, IVersionedOrchestratorFactory { - readonly IDictionary> activities; - readonly IDictionary> orchestrators; + readonly IDictionary> activities; + readonly IDictionary> orchestrators; readonly IDictionary> entities; /// @@ -22,8 +22,8 @@ sealed class DurableTaskFactory : IDurableTaskFactory2 /// The orchestrator factories. /// The entity factories. internal DurableTaskFactory( - IDictionary> activities, - IDictionary> orchestrators, + IDictionary> activities, + IDictionary> orchestrators, IDictionary> entities) { this.activities = Check.NotNull(activities); @@ -33,10 +33,23 @@ internal DurableTaskFactory( /// public bool TryCreateActivity( - TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskActivity? activity) + TaskName name, + TaskVersion version, + IServiceProvider serviceProvider, + bool allowVersionFallback, + [NotNullWhen(true)] out ITaskActivity? activity) { Check.NotNull(serviceProvider); - if (this.activities.TryGetValue(name, out Func? factory)) + ActivityVersionKey key = new(name, version); + if (this.activities.TryGetValue(key, out Func? factory)) + { + activity = factory.Invoke(serviceProvider); + return true; + } + + if (allowVersionFallback + && !string.IsNullOrWhiteSpace(version.Version) + && this.activities.TryGetValue(new ActivityVersionKey(name, default(TaskVersion)), out factory)) { activity = factory.Invoke(serviceProvider); return true; @@ -46,11 +59,30 @@ public bool TryCreateActivity( return false; } + /// + public bool TryCreateActivity( + TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskActivity? activity) + => this.TryCreateActivity(name, default(TaskVersion), serviceProvider, allowVersionFallback: false, out activity); + /// public bool TryCreateOrchestrator( - TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskOrchestrator? orchestrator) + TaskName name, + TaskVersion version, + IServiceProvider serviceProvider, + [NotNullWhen(true)] out ITaskOrchestrator? orchestrator) { - if (this.orchestrators.TryGetValue(name, out Func? factory)) + Check.NotNull(serviceProvider); + OrchestratorVersionKey key = new(name, version); + if (this.orchestrators.TryGetValue(key, out Func? factory)) + { + orchestrator = factory.Invoke(serviceProvider); + return true; + } + + // Unversioned registrations remain the compatibility fallback when a caller requests a version that has + // no exact match for the logical orchestrator name. + if (!string.IsNullOrWhiteSpace(version.Version) + && this.orchestrators.TryGetValue(new OrchestratorVersionKey(name, default(TaskVersion)), out factory)) { orchestrator = factory.Invoke(serviceProvider); return true; @@ -60,6 +92,11 @@ public bool TryCreateOrchestrator( return false; } + /// + public bool TryCreateOrchestrator( + TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskOrchestrator? orchestrator) + => this.TryCreateOrchestrator(name, default(TaskVersion), serviceProvider, out orchestrator); + /// public bool TryCreateEntity( TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskEntity? entity) diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index 8a5df2f1d..1caa9882a 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -35,32 +35,66 @@ public class DurableTaskWorkerWorkItemFilters /// A new instance of constructed from the provided registry. internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(DurableTaskRegistry registry, DurableTaskWorkerOptions? workerOptions) { - // TODO: Support multiple versions per orchestration/activity. - // For now, fetch the version based on the versioning match strategy if defined. If undefined, default to null (all versions match). - IReadOnlyList versions = []; - if (workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict) - { - versions = [workerOptions.Versioning.Version]; - } + IReadOnlyList? strictWorkerVersions = + workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict + && !string.IsNullOrWhiteSpace(workerOptions.Versioning.Version) + ? [workerOptions.Versioning.Version] + : null; - return new DurableTaskWorkerWorkItemFilters - { - Orchestrations = registry.Orchestrators.Select(orchestration => new OrchestrationFilter + // Orchestration filters now group registrations by logical name. Version lists are only emitted when every + // registration for a logical name is explicitly versioned; otherwise, the filter conservatively matches all + // versions for that name. + List orchestrationFilters = registry.Orchestrators + .GroupBy(orchestration => orchestration.Key.Name, StringComparer.OrdinalIgnoreCase) + .Select(group => { - Name = orchestration.Key, - Versions = versions, - }).ToList(), - Activities = registry.Activities.Select(activity => new ActivityFilter + IReadOnlyList versions = strictWorkerVersions ?? GetRegistrationVersions(group.Select(entry => entry.Key.Version)); + + return new OrchestrationFilter + { + Name = group.Key, + Versions = versions, + }; + }) + .ToList(); + + List activityFilters = registry.Activities + .GroupBy(activity => activity.Key.Name, StringComparer.OrdinalIgnoreCase) + .Select(group => { - Name = activity.Key, - Versions = versions, - }).ToList(), + IReadOnlyList versions = strictWorkerVersions ?? GetRegistrationVersions(group.Select(entry => entry.Key.Version)); + + return new ActivityFilter + { + Name = group.Key, + Versions = versions, + }; + }) + .ToList(); + + return new DurableTaskWorkerWorkItemFilters + { + Orchestrations = orchestrationFilters, + Activities = activityFilters, Entities = registry.Entities.Select(entity => new EntityFilter { // Entity names are normalized to lowercase in the backend. Name = entity.Key.ToString(), }).ToList(), }; + + static IReadOnlyList GetRegistrationVersions(IEnumerable versions) + { + bool hasUnversionedRegistration = versions.Any(string.IsNullOrWhiteSpace); + return hasUnversionedRegistration + ? [] + : versions + .Where(version => !string.IsNullOrWhiteSpace(version)) + .Cast() + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } } /// diff --git a/src/Worker/Core/IVersionedActivityFactory.cs b/src/Worker/Core/IVersionedActivityFactory.cs new file mode 100644 index 000000000..45f31109b --- /dev/null +++ b/src/Worker/Core/IVersionedActivityFactory.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.DurableTask.Worker; + +/// +/// Creates activity instances by logical name and requested version. +/// Callers can choose whether an unversioned registration may satisfy a versioned request when no exact match exists. +/// +internal interface IVersionedActivityFactory +{ + /// + /// Tries to create an activity that matches the provided logical name and version. + /// + /// The activity name. + /// The activity version. + /// The service provider. + /// + /// true to allow an unversioned registration to satisfy a versioned request when no exact match exists; + /// otherwise, false. + /// + /// The created activity, if found. + /// true if a matching activity was created; otherwise false. + bool TryCreateActivity( + TaskName name, + TaskVersion version, + IServiceProvider serviceProvider, + bool allowVersionFallback, + [NotNullWhen(true)] out ITaskActivity? activity); +} diff --git a/src/Worker/Core/IVersionedOrchestratorFactory.cs b/src/Worker/Core/IVersionedOrchestratorFactory.cs new file mode 100644 index 000000000..625f03e56 --- /dev/null +++ b/src/Worker/Core/IVersionedOrchestratorFactory.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.DurableTask.Worker; + +/// +/// Creates orchestrator instances by exact logical name and version. +/// +internal interface IVersionedOrchestratorFactory +{ + /// + /// Tries to create an orchestrator that matches the provided logical name and version. + /// + /// The orchestrator name. + /// The orchestrator version. + /// The service provider. + /// The created orchestrator, if found. + /// true if a matching orchestrator was created; otherwise false. + bool TryCreateOrchestrator( + TaskName name, + TaskVersion version, + IServiceProvider serviceProvider, + [NotNullWhen(true)] out ITaskOrchestrator? orchestrator); +} diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index bfbf1e47f..9d6c62764 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -128,6 +128,43 @@ public override async Task CallActivityAsync( object? input = null, TaskOptions? options = null) { + static (string RequestedVersion, bool ExplicitVersionRequested) GetRequestedActivityVersion( + TaskOptions? taskOptions, + string inheritedVersion) + { + if (taskOptions is ActivityOptions activityOptions + && activityOptions.Version is TaskVersion explicitVersion + && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + { + return (explicitVersion.Version, true); + } + + return (inheritedVersion, false); + } + + static IDictionary GetActivityTags(TaskOptions? taskOptions, bool explicitVersionRequested) + { + Dictionary tags = new(StringComparer.Ordinal); + + if (taskOptions?.Tags is not null) + { + foreach ((string key, string value) in taskOptions.Tags) + { + if (key != ActivityVersioning.ExplicitVersionTagName) + { + tags[key] = value; + } + } + } + + if (explicitVersionRequested) + { + tags[ActivityVersioning.ExplicitVersionTagName] = bool.TrueString; + } + + return tags; + } + // Since the input parameter takes any object, it's possible that callers may accidentally provide a // TaskOptions parameter here when the actually meant to provide TaskOptions for the optional options // parameter. @@ -142,14 +179,9 @@ public override async Task CallActivityAsync( try { - IDictionary tags = ImmutableDictionary.Empty; - if (options is TaskOptions callActivityOptions) - { - if (callActivityOptions.Tags is not null) - { - tags = callActivityOptions.Tags; - } - } + (string requestedVersion, bool explicitVersionRequested) = + GetRequestedActivityVersion(options, this.innerContext.Version); + IDictionary tags = GetActivityTags(options, explicitVersionRequested); // TODO: Cancellation (https://github.com/microsoft/durabletask-dotnet/issues/7) #pragma warning disable 0618 @@ -157,7 +189,7 @@ public override async Task CallActivityAsync( { return await this.innerContext.ScheduleTask( name.Name, - this.innerContext.Version, + requestedVersion, options: ScheduleTaskOptions.CreateBuilder() .WithRetryOptions(policy.ToDurableTaskCoreRetryOptions()) .WithTags(tags) @@ -169,7 +201,7 @@ public override async Task CallActivityAsync( return await this.InvokeWithCustomRetryHandler( () => this.innerContext.ScheduleTask( name.Name, - this.innerContext.Version, + requestedVersion, options: ScheduleTaskOptions.CreateBuilder() .WithTags(tags) .Build(), @@ -182,7 +214,7 @@ public override async Task CallActivityAsync( { return await this.innerContext.ScheduleTask( name.Name, - this.innerContext.Version, + requestedVersion, options: ScheduleTaskOptions.CreateBuilder() .WithTags(tags) .Build(), diff --git a/src/Worker/Core/Worker.csproj b/src/Worker/Core/Worker.csproj index daed82b15..c4832a162 100644 --- a/src/Worker/Core/Worker.csproj +++ b/src/Worker/Core/Worker.csproj @@ -19,6 +19,10 @@ The worker is responsible for processing durable task work items. + + + + diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index 58a6db040..2998c51bb 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -618,6 +618,9 @@ await this.client.AbandonTaskOrchestratorWorkItemAsync( // Only continue with the work if the versioning check passed. if (failureDetails == null) { + TaskVersion requestedVersion = string.IsNullOrWhiteSpace(runtimeState.Version) + ? default + : new TaskVersion(runtimeState.Version); name = new TaskName(runtimeState.Name); this.Logger.ReceivedOrchestratorRequest( @@ -627,8 +630,17 @@ await this.client.AbandonTaskOrchestratorWorkItemAsync( runtimeState.NewEvents.Count); await using AsyncServiceScope scope = this.worker.services.CreateAsyncScope(); - if (this.worker.Factory.TryCreateOrchestrator( - name, scope.ServiceProvider, out ITaskOrchestrator? orchestrator)) + bool found = this.worker.Factory is IVersionedOrchestratorFactory versionedFactory + ? versionedFactory.TryCreateOrchestrator( + name, + requestedVersion, + scope.ServiceProvider, + out ITaskOrchestrator? orchestrator) + : this.worker.Factory.TryCreateOrchestrator( + name, + scope.ServiceProvider, + out orchestrator); + if (found) { // Both the factory invocation and the ExecuteAsync could involve user code and need to be handled // as part of try/catch. @@ -650,10 +662,13 @@ await this.client.AbandonTaskOrchestratorWorkItemAsync( } else { + string versionText = requestedVersion.Version ?? string.Empty; failureDetails = new P.TaskFailureDetails { ErrorType = "OrchestratorTaskNotFound", - ErrorMessage = $"No orchestrator task named '{name}' was found.", + ErrorMessage = string.IsNullOrEmpty(versionText) + ? $"No orchestrator task named '{name}' was found." + : $"No orchestrator task named '{name}' with version '{versionText}' was found.", IsNonRetriable = true, }; } @@ -787,7 +802,44 @@ async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, try { await using AsyncServiceScope scope = this.worker.services.CreateAsyncScope(); - if (this.worker.Factory.TryCreateActivity(name, scope.ServiceProvider, out ITaskActivity? activity)) + TaskVersion requestedVersion = string.IsNullOrWhiteSpace(request.Version) + ? default + : new TaskVersion(request.Version); + ITaskActivity? activity; + bool found; + if (this.worker.Factory is IVersionedActivityFactory versionedFactory) + { + found = versionedFactory.TryCreateActivity( + name, + requestedVersion, + scope.ServiceProvider, + allowVersionFallback: false, + out activity); + + if (!found && !string.IsNullOrWhiteSpace(requestedVersion.Version)) + { + bool explicitVersionRequested = + request.Tags.TryGetValue(ActivityVersioning.ExplicitVersionTagName, out string? tagValue) + && bool.TryParse(tagValue, out bool parsedTagValue) + && parsedTagValue; + bool allowVersionFallback = !explicitVersionRequested; + if (allowVersionFallback) + { + found = versionedFactory.TryCreateActivity( + name, + requestedVersion, + scope.ServiceProvider, + allowVersionFallback: true, + out activity); + } + } + } + else + { + found = this.worker.Factory.TryCreateActivity(name, scope.ServiceProvider, out activity); + } + + if (found) { // Both the factory invocation and the RunAsync could involve user code and need to be handled as // part of try/catch. @@ -796,10 +848,13 @@ async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, } else { + string versionText = requestedVersion.Version ?? string.Empty; failureDetails = new P.TaskFailureDetails { ErrorType = "ActivityTaskNotFound", - ErrorMessage = $"No activity task named '{name}' was found.", + ErrorMessage = string.IsNullOrEmpty(versionText) || this.worker.Factory is not IVersionedActivityFactory + ? $"No activity task named '{name}' was found." + : $"No activity task named '{name}' with version '{versionText}' was found.", IsNonRetriable = true, }; } diff --git a/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs b/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs new file mode 100644 index 000000000..2164fe12f --- /dev/null +++ b/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Tests; + +public class DurableTaskRegistryVersioningTests +{ + [Fact] + public void AddOrchestrator_SameLogicalNameDifferentVersions_DoesNotThrow() + { + // Arrange + DurableTaskRegistry registry = new(); + + // Act + Action act = () => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + }; + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddOrchestrator_SameLogicalNameAndVersion_Throws() + { + // Arrange + DurableTaskRegistry registry = new(); + + // Act + Action act = () => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + }; + + // Assert + act.Should().ThrowExactly().WithParameterName("name"); + } + + [Fact] + public void AddOrchestrator_ExplicitVersionFactory_SameLogicalNameDifferentVersions_DoesNotThrow() + { + // Arrange + DurableTaskRegistry registry = new(); + + // Act + Action act = () => + { + registry.AddOrchestrator("ManualWorkflow", new TaskVersion("v1"), () => new ManualWorkflow("v1")); + registry.AddOrchestrator("ManualWorkflow", new TaskVersion("v2"), () => new ManualWorkflow("v2")); + }; + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddActivity_SameLogicalNameDifferentVersions_DoesNotThrow() + { + // Arrange + DurableTaskRegistry registry = new(); + + // Act + Action act = () => + { + registry.AddActivity(); + registry.AddActivity(); + }; + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddActivity_SameLogicalNameAndVersion_Throws() + { + // Arrange + DurableTaskRegistry registry = new(); + + // Act + Action act = () => + { + registry.AddActivity(); + registry.AddActivity(); + }; + + // Assert + act.Should().ThrowExactly().WithParameterName("name"); + } + + [Fact] + public void AddActivity_ExplicitVersionFactory_SameLogicalNameDifferentVersions_DoesNotThrow() + { + // Arrange + DurableTaskRegistry registry = new(); + + // Act + Action act = () => + { + registry.AddActivity("ManualActivity", new TaskVersion("v1"), () => new ManualActivity("v1")); + registry.AddActivity("ManualActivity", new TaskVersion("v2"), () => new ManualActivity("v2")); + }; + + // Assert + act.Should().NotThrow(); + } + + [DurableTask("ShippingWorkflow")] + [DurableTaskVersion("v1")] + sealed class ShippingWorkflowV1 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult("v1"); + } + + [DurableTask("ShippingWorkflow")] + [DurableTaskVersion("v2")] + sealed class ShippingWorkflowV2 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult("v2"); + } + + [DurableTask("DuplicateWorkflow")] + [DurableTaskVersion("v1")] + sealed class DuplicateShippingWorkflowV1 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult("v1"); + } + + [DurableTask("DuplicateWorkflow")] + [DurableTaskVersion("v1")] + sealed class DuplicateShippingWorkflowV1Copy : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult("v1-copy"); + } + + [DurableTask("ShippingActivity")] + [DurableTaskVersion("v1")] + sealed class ShippingActivityV1 : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult("v1"); + } + + [DurableTask("ShippingActivity")] + [DurableTaskVersion("v2")] + sealed class ShippingActivityV2 : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult("v2"); + } + + [DurableTask("DuplicateActivity")] + [DurableTaskVersion("v1")] + sealed class DuplicateShippingActivityV1 : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult("v1"); + } + + [DurableTask("DuplicateActivity")] + [DurableTaskVersion("v1")] + sealed class DuplicateShippingActivityV1Copy : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult("v1-copy"); + } + + sealed class ManualWorkflow : TaskOrchestrator + { + readonly string marker; + + public ManualWorkflow(string marker) + { + this.marker = marker; + } + + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult(this.marker); + } + + sealed class ManualActivity : TaskActivity + { + readonly string marker; + + public ManualActivity(string marker) + { + this.marker = marker; + } + + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult(this.marker); + } +} diff --git a/test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs b/test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs new file mode 100644 index 000000000..cca3f8ba3 --- /dev/null +++ b/test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Tests; + +public class DurableTaskVersionAttributeTests +{ + [Fact] + public void Ctor_WithVersion_PreservesTaskVersion() + { + // Arrange + DurableTaskVersionAttribute attribute = new("v2"); + + // Act + string? version = attribute.Version.Version; + + // Assert + version.Should().Be("v2"); + } + + [Fact] + public void GetDurableTaskVersion_WithAttribute_ReturnsVersion() + { + // Arrange + Type type = typeof(VersionedTestOrchestrator); + + // Act + TaskVersion version = type.GetDurableTaskVersion(); + + // Assert + version.Version.Should().Be("v1"); + } + + [Fact] + public void GetDurableTaskVersion_WithoutAttribute_ReturnsDefault() + { + // Arrange + Type type = typeof(UnversionedTestOrchestrator); + + // Act + TaskVersion version = type.GetDurableTaskVersion(); + + // Assert + version.Should().Be(default(TaskVersion)); + } + + [DurableTaskVersion("v1")] + sealed class VersionedTestOrchestrator : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult(input); + } + + sealed class UnversionedTestOrchestrator : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult(input); + } +} diff --git a/test/Abstractions.Tests/TaskOptionsTests.cs b/test/Abstractions.Tests/TaskOptionsTests.cs index ad5b9c863..2a215efbf 100644 --- a/test/Abstractions.Tests/TaskOptionsTests.cs +++ b/test/Abstractions.Tests/TaskOptionsTests.cs @@ -8,16 +8,21 @@ namespace Microsoft.DurableTask.Tests; public class TaskOptionsTests { [Fact] - public void Empty_Ctors_Okay() - { - TaskOptions options = new(); - options.Retry.Should().BeNull(); - options.Tags.Should().BeNull(); - - SubOrchestrationOptions subOptions = new(); - subOptions.Retry.Should().BeNull(); - subOptions.Tags.Should().BeNull(); - subOptions.InstanceId.Should().BeNull(); + public void Empty_Ctors_Okay() + { + TaskOptions options = new(); + options.Retry.Should().BeNull(); + options.Tags.Should().BeNull(); + + ActivityOptions activityOptions = new(); + activityOptions.Retry.Should().BeNull(); + activityOptions.Tags.Should().BeNull(); + activityOptions.Version.Should().BeNull(); + + SubOrchestrationOptions subOptions = new(); + subOptions.Retry.Should().BeNull(); + subOptions.Tags.Should().BeNull(); + subOptions.InstanceId.Should().BeNull(); StartOrchestrationOptions startOptions = new(); startOptions.Version.Should().BeNull(); @@ -154,11 +159,11 @@ public void WithDedupeStatuses_ConvertsAllEnumValuesToStrings() } [Fact] - public void TaskOptions_CopyConstructor_CopiesAllProperties() - { - // Arrange - RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); - TaskRetryOptions retry = new(policy); + public void TaskOptions_CopyConstructor_CopiesAllProperties() + { + // Arrange + RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); + TaskRetryOptions retry = new(policy); Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; TaskOptions original = new(retry, tags); @@ -166,16 +171,80 @@ public void TaskOptions_CopyConstructor_CopiesAllProperties() TaskOptions copy = new(original); // Assert - copy.Retry.Should().Be(original.Retry); - copy.Tags.Should().BeSameAs(original.Tags); - } - - [Fact] - public void SubOrchestrationOptions_CopyConstructor_CopiesAllProperties() - { - // Arrange - RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); - TaskRetryOptions retry = new(policy); + copy.Retry.Should().Be(original.Retry); + copy.Tags.Should().BeSameAs(original.Tags); + } + + [Fact] + public void ActivityOptions_CopyConstructor_CopiesAllProperties() + { + // Arrange + RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); + TaskRetryOptions retry = new(policy); + Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; + TaskVersion version = new("1.0"); + ActivityOptions original = new(retry) + { + Tags = tags, + Version = version, + }; + + // Act + ActivityOptions copy = new(original); + + // Assert + copy.Retry.Should().Be(original.Retry); + copy.Tags.Should().BeSameAs(original.Tags); + copy.Version.Should().Be(original.Version); + } + + [Fact] + public void ActivityOptions_CopyFromTaskOptions_PreservesRetryAndTagsButLeavesVersionNull() + { + // Arrange + RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); + TaskRetryOptions retry = new(policy); + Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; + TaskOptions original = new(retry, tags); + + // Act + ActivityOptions copy = new(original); + + // Assert + copy.Retry.Should().Be(original.Retry); + copy.Tags.Should().BeSameAs(original.Tags); + copy.Version.Should().BeNull(); + } + + [Fact] + public void ActivityOptions_CopyFromTaskOptions_CopiesVersionWhenSourceIsActivityOptions() + { + // Arrange + RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); + TaskRetryOptions retry = new(policy); + Dictionary tags = new() { { "key1", "value1" } }; + TaskVersion version = new("1.0"); + ActivityOptions original = new(retry) + { + Tags = tags, + Version = version, + }; + + // Act + ActivityOptions copy = new(original as TaskOptions); + + // Assert + copy.Retry.Should().Be(original.Retry); + copy.Tags.Should().BeSameAs(original.Tags); + copy.Version.Should().Be(original.Version); + } + + [Fact] + public void SubOrchestrationOptions_CopyConstructor_CopiesAllProperties() + { + // Arrange + RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); + TaskRetryOptions retry = new(policy); Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; string instanceId = Guid.NewGuid().ToString(); TaskVersion version = new("1.0"); diff --git a/test/Generators.Tests/AzureFunctionsTests.cs b/test/Generators.Tests/AzureFunctionsTests.cs index ac2d81992..fc412a068 100644 --- a/test/Generators.Tests/AzureFunctionsTests.cs +++ b/test/Generators.Tests/AzureFunctionsTests.cs @@ -2,7 +2,10 @@ // Licensed under the MIT License. using Microsoft.Azure.Functions.Worker; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; using Microsoft.DurableTask.Generators.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.DurableTask.Generators.Tests; @@ -416,6 +419,124 @@ await TestHelpers.RunTestAsync( isDurableFunctions: true); } + [Fact] + public Task Orchestrators_ClassBasedSyntax_DuplicateLogicalNameAcrossVersions_ReportsDiagnostic() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.Extensions.DependencyInjection; + +namespace MyFunctions +{ + [DurableTask(""PaymentWorkflow"")] + [DurableTaskVersion(""v1"")] + class PaymentWorkflowV1 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); + } + + [DurableTask(""PaymentWorkflow"")] + [DurableTaskVersion(""v2"")] + class PaymentWorkflowV2 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); + } +}"; + + DiagnosticResult firstExpected = new DiagnosticResult("DURABLE3004", DiagnosticSeverity.Error) + .WithSpan("/0/Test0.cs", 9, 18, 9, 35) + .WithArguments("PaymentWorkflow"); + DiagnosticResult secondExpected = new DiagnosticResult("DURABLE3004", DiagnosticSeverity.Error) + .WithSpan("/0/Test0.cs", 16, 18, 16, 35) + .WithArguments("PaymentWorkflow"); + + CSharpSourceGeneratorVerifier.Test test = new() + { + TestState = + { + Sources = { code }, + ExpectedDiagnostics = { firstExpected, secondExpected }, + AdditionalReferences = + { + typeof(TaskActivityContext).Assembly, + typeof(FunctionAttribute).Assembly, + typeof(FunctionContext).Assembly, + typeof(OrchestrationTriggerAttribute).Assembly, + typeof(ActivatorUtilities).Assembly, + }, + }, + }; + + return test.RunAsync(); + } + + [Fact] + public Task Orchestrators_ClassBasedSyntax_CollidesWithMethodBasedTrigger_ReportsDiagnostic() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.Extensions.DependencyInjection; + +namespace MyFunctions +{ + [DurableTask(""PaymentWorkflow"")] + class PaymentWorkflowOrchestrator : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); + } + + class ExistingFunctions + { + [Function(""PaymentWorkflow"")] + public Task PaymentWorkflow([OrchestrationTrigger] TaskOrchestrationContext context) => Task.FromResult(string.Empty); + } +}"; + + string expectedOutput = """ +// +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Internal; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.DependencyInjection; +"""; + + DiagnosticResult expected = new DiagnosticResult("DURABLE3004", DiagnosticSeverity.Error) + .WithSpan("/0/Test0.cs", 9, 18, 9, 35) + .WithArguments("PaymentWorkflow"); + + CSharpSourceGeneratorVerifier.Test test = new() + { + TestState = + { + Sources = { code }, + GeneratedSources = + { + (typeof(DurableTaskSourceGenerator), GeneratedFileName, Microsoft.CodeAnalysis.Text.SourceText.From(expectedOutput, System.Text.Encoding.UTF8, Microsoft.CodeAnalysis.Text.SourceHashAlgorithm.Sha256)), + }, + ExpectedDiagnostics = { expected }, + AdditionalReferences = + { + typeof(TaskActivityContext).Assembly, + typeof(FunctionAttribute).Assembly, + typeof(FunctionContext).Assembly, + typeof(OrchestrationTriggerAttribute).Assembly, + typeof(ActivatorUtilities).Assembly, + }, + }, + }; + + return test.RunAsync(); + } + /// /// Verifies that using the class-based syntax for authoring entities generates /// function triggers for Azure Functions. diff --git a/test/Generators.Tests/VersionedActivityTests.cs b/test/Generators.Tests/VersionedActivityTests.cs new file mode 100644 index 000000000..a529284d0 --- /dev/null +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using Microsoft.DurableTask.Generators.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.DurableTask.Generators.Tests; + +public class VersionedActivityTests +{ + const string GeneratedClassName = "GeneratedDurableTaskExtensions"; + const string GeneratedFileName = $"{GeneratedClassName}.cs"; + + [Fact] + public Task Standalone_SingleVersionedActivity_GeneratesUnsuffixedHelper() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceActivity"")] +[DurableTaskVersion(""v1"")] +class InvoiceActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallInvoiceActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v1"")); +} + +static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) +{ + if (options is ActivityOptions activityOptions + && activityOptions.Version is TaskVersion explicitVersion + && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + { + return options; + } + + if (options is ActivityOptions existingActivityOptions) + { + return new ActivityOptions(existingActivityOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new ActivityOptions + { + Version = version, + }; + } + + return new ActivityOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddActivity(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Standalone_MultiVersionedActivities_GenerateVersionQualifiedHelpersOnly() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceActivity"")] +[DurableTaskVersion(""v1"")] +class InvoiceActivityV1 : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""InvoiceActivity"")] +[DurableTaskVersion(""v2"")] +class InvoiceActivityV2 : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallInvoiceActivity_v1Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v1"")); +} + +/// +/// Calls the activity. +/// +/// +public static Task CallInvoiceActivity_v2Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v2"")); +} + +static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) +{ + if (options is ActivityOptions activityOptions + && activityOptions.Version is TaskVersion explicitVersion + && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + { + return options; + } + + if (options is ActivityOptions existingActivityOptions) + { + return new ActivityOptions(existingActivityOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new ActivityOptions + { + Version = version, + }; + } + + return new ActivityOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddActivity(); + builder.AddActivity(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Standalone_DuplicateLogicalNameAndVersion_ReportsDiagnostic() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceActivity"")] +[DurableTaskVersion(""v1"")] +class InvoiceActivityV1 : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""InvoiceActivity"")] +[DurableTaskVersion(""v1"")] +class InvoiceActivityV1Duplicate : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallInvoiceActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v1"")); +} + +static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) +{ + if (options is ActivityOptions activityOptions + && activityOptions.Version is TaskVersion explicitVersion + && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + { + return options; + } + + if (options is ActivityOptions existingActivityOptions) + { + return new ActivityOptions(existingActivityOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new ActivityOptions + { + Version = version, + }; + } + + return new ActivityOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddActivity(); + return builder; +}"); + + DiagnosticResult expected = new DiagnosticResult("DURABLE3003", DiagnosticSeverity.Error) + .WithSpan("/0/Test0.cs", 12, 14, 12, 31) + .WithArguments("InvoiceActivity", "v1"); + + CSharpSourceGeneratorVerifier.Test test = new() + { + TestState = + { + Sources = { code }, + GeneratedSources = + { + (typeof(DurableTaskSourceGenerator), GeneratedFileName, SourceText.From(expectedOutput, System.Text.Encoding.UTF8, SourceHashAlgorithm.Sha256)), + }, + ExpectedDiagnostics = { expected }, + AdditionalReferences = + { + typeof(TaskActivityContext).Assembly, + }, + }, + }; + + return test.RunAsync(); + } + + [Fact] + public Task AzureFunctions_ClassBasedActivities_DuplicateLogicalNameAcrossVersions_ReportsDiagnostic() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.Extensions.DependencyInjection; + +namespace MyFunctions +{ + [DurableTask(""PaymentActivity"")] + [DurableTaskVersion(""v1"")] + class PaymentActivityV1 : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); + } + + [DurableTask(""PaymentActivity"")] + [DurableTaskVersion(""v2"")] + class PaymentActivityV2 : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); + } +}"; + + DiagnosticResult firstExpected = new DiagnosticResult("DURABLE3004", DiagnosticSeverity.Error) + .WithSpan("/0/Test0.cs", 9, 18, 9, 35) + .WithArguments("PaymentActivity"); + DiagnosticResult secondExpected = new DiagnosticResult("DURABLE3004", DiagnosticSeverity.Error) + .WithSpan("/0/Test0.cs", 16, 18, 16, 35) + .WithArguments("PaymentActivity"); + + CSharpSourceGeneratorVerifier.Test test = new() + { + TestState = + { + Sources = { code }, + ExpectedDiagnostics = { firstExpected, secondExpected }, + AdditionalReferences = + { + typeof(TaskActivityContext).Assembly, + typeof(FunctionAttribute).Assembly, + typeof(FunctionContext).Assembly, + typeof(ActivityTriggerAttribute).Assembly, + typeof(ActivatorUtilities).Assembly, + }, + }, + }; + + return test.RunAsync(); + } +} diff --git a/test/Generators.Tests/VersionedOrchestratorTests.cs b/test/Generators.Tests/VersionedOrchestratorTests.cs new file mode 100644 index 000000000..29d14f5e2 --- /dev/null +++ b/test/Generators.Tests/VersionedOrchestratorTests.cs @@ -0,0 +1,619 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using Microsoft.DurableTask.Generators.Tests.Utils; + +namespace Microsoft.DurableTask.Generators.Tests; + +public class VersionedOrchestratorTests +{ + const string GeneratedClassName = "GeneratedDurableTaskExtensions"; + const string GeneratedFileName = $"{GeneratedClassName}.cs"; + + [Fact] + public Task Standalone_SingleVersionedOrchestrator_GeneratesVersionAwareHelpers() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflow : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflowInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflowAsync( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Standalone_MultiVersionedOrchestrators_GenerateVersionQualifiedHelpersOnly() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v2"")] +class InvoiceWorkflowV2 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflow_v1InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflow_v1Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflow_v2InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflow_v2Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + builder.AddOrchestrator(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Standalone_CaseInsensitiveLogicalNameGrouping_GeneratesVersionQualifiedHelpersOnly() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""invoiceworkflow"")] +[DurableTaskVersion(""v2"")] +class InvoiceWorkflowV2 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflow_v1InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflow_v1Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewinvoiceworkflow_v2InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""invoiceworkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task Callinvoiceworkflow_v2Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""invoiceworkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + builder.AddOrchestrator(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Standalone_DuplicateLogicalNameAndVersion_ReportsDiagnostic() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflowV1Duplicate : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflowInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflowAsync( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + return builder; +}"); + + DiagnosticResult expected = new DiagnosticResult("DURABLE3003", DiagnosticSeverity.Error) + .WithSpan("/0/Test0.cs", 12, 14, 12, 31) + .WithArguments("InvoiceWorkflow", "v1"); + + CSharpSourceGeneratorVerifier.Test test = new() + { + TestState = + { + Sources = { code }, + GeneratedSources = + { + (typeof(DurableTaskSourceGenerator), GeneratedFileName, SourceText.From(expectedOutput, System.Text.Encoding.UTF8, SourceHashAlgorithm.Sha256)), + }, + ExpectedDiagnostics = { expected }, + AdditionalReferences = + { + typeof(TaskActivityContext).Assembly, + }, + }, + }; + + return test.RunAsync(); + } + + [Fact] + public Task Standalone_DuplicateLogicalNameAndVersion_DifferingOnlyByCase_ReportsDiagnostic() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""invoiceworkflow"")] +[DurableTaskVersion(""V1"")] +class InvoiceWorkflowV1Duplicate : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflowInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflowAsync( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + return builder; +}"); + + DiagnosticResult expected = new DiagnosticResult("DURABLE3003", DiagnosticSeverity.Error) + .WithSpan("/0/Test0.cs", 12, 14, 12, 31) + .WithArguments("invoiceworkflow", "V1"); + + CSharpSourceGeneratorVerifier.Test test = new() + { + TestState = + { + Sources = { code }, + GeneratedSources = + { + (typeof(DurableTaskSourceGenerator), GeneratedFileName, SourceText.From(expectedOutput, System.Text.Encoding.UTF8, SourceHashAlgorithm.Sha256)), + }, + ExpectedDiagnostics = { expected }, + AdditionalReferences = + { + typeof(TaskActivityContext).Assembly, + }, + }, + }; + + return test.RunAsync(); + } +} diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs new file mode 100644 index 000000000..bb3598de0 --- /dev/null +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using DurableTask.Core.History; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Testing.Sidecar.Grpc; +using Microsoft.DurableTask.Worker; +using Xunit.Abstractions; +using static Microsoft.DurableTask.Grpc.Tests.VersionedClassSyntaxTestOrchestration; + +namespace Microsoft.DurableTask.Grpc.Tests; + +/// +/// Integration tests for class-based versioned orchestrators. +/// +public class VersionedClassSyntaxIntegrationTests : IntegrationTestBase +{ + /// + /// Initializes a new instance of the class. + /// + public VersionedClassSyntaxIntegrationTests(ITestOutputHelper output, GrpcSidecarFixture sidecarFixture) + : base(output, sidecarFixture) + { } + + /// + /// Verifies explicit orchestration versions route to the matching class-based orchestrator. + /// + [Fact] + public async Task ClassBasedVersionedOrchestrator_ExplicitVersionRoutesMatchingClass() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddOrchestrator(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "VersionedClassSyntax", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("v2"), + }); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("v2:5", metadata.ReadOutputAs()); + } + + /// + /// Verifies explicit activity versions override the inherited orchestration version. + /// + [Fact] + public async Task ClassBasedVersionedActivity_ExplicitActivityVersionOverridesOrchestrationVersion() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + tasks.AddActivity(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "VersionedActivityOverrideOrchestration", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("v2"), + }); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("activity-v1:5", metadata.ReadOutputAs()); + } + + /// + /// Verifies explicit activity version selection does not fall back to an unversioned registration. + /// + [Fact] + public async Task ClassBasedVersionedActivity_ExplicitActivityVersionDoesNotFallBackToUnversionedRegistration() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "ExplicitActivityVersionNoFallbackOrchestration", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("v2"), + }); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Failed, metadata.RuntimeStatus); + Assert.NotNull(metadata.FailureDetails); + Assert.Equal(typeof(TaskFailedException).FullName, metadata.FailureDetails.ErrorType); + Assert.NotNull(metadata.FailureDetails.InnerFailure); + Assert.Equal("ActivityTaskNotFound", metadata.FailureDetails.InnerFailure.ErrorType); + Assert.Contains( + "No activity task named 'ExplicitActivityVersionNoFallbackActivity' with version 'v1' was found.", + metadata.FailureDetails.InnerFailure.ErrorMessage); + } + + /// + /// Verifies inherited orchestration-version activity routing still falls back to an unversioned registration. + /// + [Fact] + public async Task ClassBasedVersionedActivity_InheritedVersionFallsBackToUnversionedRegistration() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "InheritedActivityVersionFallbackOrchestration", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("v2"), + }); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("activity-unversioned:5", metadata.ReadOutputAs()); + } + + /// + /// Verifies user-supplied tags cannot spoof the internal explicit-version marker. + /// + [Fact] + public async Task ClassBasedVersionedActivity_UserSuppliedReservedTagDoesNotDisableInheritedFallback() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "SpoofedActivityVersionTagFallbackOrchestration", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("v2"), + }); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("activity-unversioned:5", metadata.ReadOutputAs()); + } + + /// + /// Verifies the in-proc task-scheduled serializer preserves activity tags. + /// + [Fact] + public void TaskScheduledEventSerialization_PreservesExplicitVersionMarker() + { + TaskScheduledEvent scheduledEvent = new( + eventId: 7, + name: "VersionedActivityOverrideActivity", + version: "v1", + input: "5") + { + Tags = new Dictionary + { + [ExplicitVersionTagName] = bool.TrueString, + }, + }; + + var proto = ProtobufUtils.ToHistoryEventProto(scheduledEvent); + + Assert.Equal("VersionedActivityOverrideActivity", proto.TaskScheduled.Name); + Assert.Equal("v1", proto.TaskScheduled.Version); + Assert.True( + proto.TaskScheduled.Tags.TryGetValue(ExplicitVersionTagName, out string? tagValue), + $"Expected tag '{ExplicitVersionTagName}' to be present."); + Assert.Equal(bool.TrueString, tagValue); + } + + /// + /// Verifies starting without a version fails when only versioned handlers are registered. + /// + [Fact] + public async Task ClassBasedVersionedOrchestrator_WithoutVersionFailsWhenOnlyVersionedHandlersExist() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddOrchestrator(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "VersionedClassSyntax", + input: 5, + this.TimeoutToken); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Failed, metadata.RuntimeStatus); + Assert.NotNull(metadata.FailureDetails); + Assert.Equal("OrchestratorTaskNotFound", metadata.FailureDetails.ErrorType); + Assert.Contains("No orchestrator task named 'VersionedClassSyntax' was found.", metadata.FailureDetails.ErrorMessage); + } + + /// + /// Verifies continue-as-new can migrate a class-based orchestration to a newer version. + /// + [Fact] + public async Task ClassBasedVersionedOrchestrator_ContinueAsNewNewVersionRoutesToNewClass() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddOrchestrator(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "VersionedContinueAsNewClassSyntax", + input: 4, + new StartOrchestrationOptions + { + Version = new TaskVersion("v1"), + }); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("v2:5", metadata.ReadOutputAs()); + } +} diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs new file mode 100644 index 000000000..e051e8ff6 --- /dev/null +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Microsoft.DurableTask.Grpc.Tests; + +/// +/// Class-based versioned orchestrators used by integration tests. +/// +public static class VersionedClassSyntaxTestOrchestration +{ + public const string ExplicitVersionTagName = "microsoft.durabletask.activity.explicit-version"; + + /// + /// Version 1 of the explicit version routing orchestration. + /// + [DurableTask("VersionedClassSyntax")] + [DurableTaskVersion("v1")] + public sealed class VersionedClassSyntaxV1 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => Task.FromResult($"v1:{input}"); + } + + /// + /// Version 2 of the explicit version routing orchestration. + /// + [DurableTask("VersionedClassSyntax")] + [DurableTaskVersion("v2")] + public sealed class VersionedClassSyntaxV2 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => Task.FromResult($"v2:{input}"); + } + + /// + /// Version 2 of the orchestration that explicitly targets an older activity version. + /// + [DurableTask("VersionedActivityOverrideOrchestration")] + [DurableTaskVersion("v2")] + public sealed class VersionedActivityOverrideOrchestrationV2 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => context.CallActivityAsync( + "VersionedActivityOverrideActivity", + input, + new ActivityOptions + { + Version = "v1", + }); + } + + /// + /// Version 1 of the explicitly-versioned activity. + /// + [DurableTask("VersionedActivityOverrideActivity")] + [DurableTaskVersion("v1")] + public sealed class VersionedActivityOverrideActivityV1 : TaskActivity + { + /// + public override Task RunAsync(TaskActivityContext context, int input) + => Task.FromResult($"activity-v1:{input}"); + } + + /// + /// Version 2 of the explicitly-versioned activity. + /// + [DurableTask("VersionedActivityOverrideActivity")] + [DurableTaskVersion("v2")] + public sealed class VersionedActivityOverrideActivityV2 : TaskActivity + { + /// + public override Task RunAsync(TaskActivityContext context, int input) + => Task.FromResult($"activity-v2:{input}"); + } + + /// + /// Version 2 of the orchestration that explicitly requests a missing activity version. + /// + [DurableTask("ExplicitActivityVersionNoFallbackOrchestration")] + [DurableTaskVersion("v2")] + public sealed class ExplicitActivityVersionNoFallbackOrchestrationV2 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => context.CallActivityAsync( + "ExplicitActivityVersionNoFallbackActivity", + input, + new ActivityOptions + { + Version = "v1", + }); + } + + /// + /// Unversioned activity used to verify explicit version requests do not silently fall back. + /// + [DurableTask("ExplicitActivityVersionNoFallbackActivity")] + public sealed class UnversionedActivityVersionNoFallbackActivity : TaskActivity + { + /// + public override Task RunAsync(TaskActivityContext context, int input) + => Task.FromResult($"activity-unversioned:{input}"); + } + + /// + /// Version 2 of the orchestration that inherits its version when calling an unversioned activity. + /// + [DurableTask("InheritedActivityVersionFallbackOrchestration")] + [DurableTaskVersion("v2")] + public sealed class InheritedActivityVersionFallbackOrchestrationV2 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => context.CallActivityAsync("InheritedActivityVersionFallbackActivity", input); + } + + /// + /// Unversioned activity used to verify inherited activity routing retains compatibility fallback behavior. + /// + [DurableTask("InheritedActivityVersionFallbackActivity")] + public sealed class UnversionedInheritedActivityVersionFallbackActivity : TaskActivity + { + /// + public override Task RunAsync(TaskActivityContext context, int input) + => Task.FromResult($"activity-unversioned:{input}"); + } + + /// + /// Version 2 of the orchestration that passes the reserved explicit-version tag in user-supplied task options. + /// + [DurableTask("SpoofedActivityVersionTagFallbackOrchestration")] + [DurableTaskVersion("v2")] + public sealed class SpoofedActivityVersionTagFallbackOrchestrationV2 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => context.CallActivityAsync( + "SpoofedActivityVersionTagFallbackActivity", + input, + new TaskOptions(tags: new Dictionary + { + [ExplicitVersionTagName] = bool.FalseString, + })); + } + + /// + /// Unversioned activity used to verify user-supplied reserved tags do not disable compatibility fallback behavior. + /// + [DurableTask("SpoofedActivityVersionTagFallbackActivity")] + public sealed class UnversionedSpoofedActivityVersionTagFallbackActivity : TaskActivity + { + /// + public override Task RunAsync(TaskActivityContext context, int input) + => Task.FromResult($"activity-unversioned:{input}"); + } + + /// + /// Version 1 of the continue-as-new orchestration. + /// + [DurableTask("VersionedContinueAsNewClassSyntax")] + [DurableTaskVersion("v1")] + public sealed class VersionedContinueAsNewClassSyntaxV1 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + { + context.ContinueAsNew(new ContinueAsNewOptions + { + NewInput = input + 1, + NewVersion = "v2", + }); + + return Task.FromResult(string.Empty); + } + } + + /// + /// Version 2 of the continue-as-new orchestration. + /// + [DurableTask("VersionedContinueAsNewClassSyntax")] + [DurableTaskVersion("v2")] + public sealed class VersionedContinueAsNewClassSyntaxV2 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => Task.FromResult($"v2:{input}"); + } +} diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index adaac30a7..2a054485c 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -181,7 +181,7 @@ public void WorkItemFilters_DefaultNullWithVersioningNone_WhenExplicitlyOptedIn( } [Fact] - public void WorkItemFilters_DefaultVersionWithVersioningStrict_WhenExplicitlyOptedIn() + public void WorkItemFilters_DefaultVersionWithVersioningStrict_NarrowsGeneratedFilters_WhenExplicitlyOptedIn() { // Arrange ServiceCollection services = new(); @@ -210,8 +210,160 @@ public void WorkItemFilters_DefaultVersionWithVersioningStrict_WhenExplicitlyOpt DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); // Assert - actual.Orchestrations.Should().ContainSingle(o => o.Name == nameof(TestOrchestrator) && o.Versions.Contains("1.0")); - actual.Activities.Should().ContainSingle(a => a.Name == nameof(TestActivity) && a.Versions.Contains("1.0")); + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be(nameof(TestOrchestrator)); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["1.0"]); + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Name.Should().Be(nameof(TestActivity)); + actual.Activities[0].Versions.Should().BeEquivalentTo(["1.0"]); + } + + [Fact] + public void WorkItemFilters_MixedRegistrationsWithVersioningStrict_UseConfiguredWorkerVersion() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddActivity(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1.0", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["1.0"]); + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Name.Should().Be("FilterActivity"); + actual.Activities[0].Versions.Should().BeEquivalentTo(["1.0"]); + } + + [Fact] + public void WorkItemFilters_VersionedOrchestrators_GroupVersionsByLogicalName() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["v1", "v2"]); + } + + [Fact] + public void WorkItemFilters_UnversionedAndVersionedOrchestrators_FallBackToNameOnlyFilter() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); + actual.Orchestrations[0].Versions.Should().BeEmpty(); + } + + [Fact] + public void WorkItemFilters_VersionedActivities_GroupVersionsByLogicalName() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddActivity(); + registry.AddActivity(); + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Name.Should().Be("FilterActivity"); + actual.Activities[0].Versions.Should().BeEquivalentTo(["v1", "v2"]); + } + + [Fact] + public void WorkItemFilters_UnversionedAndVersionedActivities_FallBackToNameOnlyFilter() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddActivity(); + registry.AddActivity(); + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Name.Should().Be("FilterActivity"); + actual.Activities[0].Versions.Should().BeEmpty(); } [Fact] @@ -402,6 +554,35 @@ public override Task RunAsync(TaskOrchestrationContext context, object i } } + [DurableTask("FilterWorkflow")] + [DurableTaskVersion("v1")] + sealed class VersionedFilterWorkflowV1 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + return Task.FromResult("v1"); + } + } + + [DurableTask("FilterWorkflow")] + [DurableTaskVersion("v2")] + sealed class VersionedFilterWorkflowV2 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + return Task.FromResult("v2"); + } + } + + [DurableTask("FilterWorkflow")] + sealed class UnversionedFilterWorkflow : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + return Task.FromResult("unversioned"); + } + } + sealed class TestActivity : TaskActivity { public override Task RunAsync(TaskActivityContext context, object input) @@ -410,7 +591,36 @@ public override Task RunAsync(TaskActivityContext context, object input) } } + [DurableTask("FilterActivity")] + [DurableTaskVersion("v1")] + sealed class VersionedFilterActivityV1 : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + { + return Task.FromResult("v1"); + } + } + + [DurableTask("FilterActivity")] + [DurableTaskVersion("v2")] + sealed class VersionedFilterActivityV2 : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + { + return Task.FromResult("v2"); + } + } + + [DurableTask("FilterActivity")] + sealed class UnversionedFilterActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + { + return Task.FromResult("unversioned"); + } + } + sealed class TestEntity : TaskEntity { } -} \ No newline at end of file +} diff --git a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs new file mode 100644 index 000000000..2fc4dc058 --- /dev/null +++ b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.Tests; + +public class DurableTaskFactoryActivityVersioningTests +{ + [Fact] + public void TryCreateActivity_WithMatchingVersion_ReturnsMatchingImplementation() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + allowVersionFallback: true, + out ITaskActivity? activity); + + // Assert + found.Should().BeTrue(); + activity.Should().BeOfType(); + } + + [Fact] + public void TryCreateActivity_WithoutMatchingVersion_ReturnsFalse() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + allowVersionFallback: true, + out ITaskActivity? activity); + + // Assert + found.Should().BeFalse(); + activity.Should().BeNull(); + } + + [Fact] + public void TryCreateActivity_WithRequestedVersion_UsesUnversionedRegistrationWhenAvailable() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + allowVersionFallback: true, + out ITaskActivity? activity); + + // Assert + found.Should().BeTrue(); + activity.Should().BeOfType(); + } + + [Fact] + public void TryCreateActivity_WithMixedRegistrations_PrefersExactVersionMatch() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + registry.AddActivity(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v1"), + Mock.Of(), + allowVersionFallback: true, + out ITaskActivity? activity); + + // Assert + found.Should().BeTrue(); + activity.Should().BeOfType(); + } + + [Fact] + public void PublicTryCreateActivity_UsesUnversionedRegistrationOnly() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = factory.TryCreateActivity( + new TaskName("InvoiceActivity"), + Mock.Of(), + out ITaskActivity? activity); + + // Assert + found.Should().BeTrue(); + activity.Should().BeOfType(); + } + + [Fact] + public void TryCreateActivity_WithRequestedVersion_DoesNotUseUnversionedRegistrationWhenFallbackIsDisallowed() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + allowVersionFallback: false, + out ITaskActivity? activity); + + // Assert + found.Should().BeFalse(); + activity.Should().BeNull(); + } + + [DurableTask("InvoiceActivity")] + [DurableTaskVersion("v1")] + sealed class InvoiceActivityV1 : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult("v1"); + } + + [DurableTask("InvoiceActivity")] + [DurableTaskVersion("v2")] + sealed class InvoiceActivityV2 : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult("v2"); + } + + [DurableTask("InvoiceActivity")] + sealed class UnversionedInvoiceActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult("unversioned"); + } +} diff --git a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs new file mode 100644 index 000000000..b737866cb --- /dev/null +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.Tests; + +public class DurableTaskFactoryVersioningTests +{ + [Fact] + public void TryCreateOrchestrator_WithMatchingVersion_ReturnsMatchingImplementation() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v2"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + + [Fact] + public void TryCreateOrchestrator_WithoutMatchingVersion_ReturnsFalse() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v2"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeFalse(); + orchestrator.Should().BeNull(); + } + + [Fact] + public void TryCreateOrchestrator_WithRequestedVersion_UsesUnversionedRegistrationWhenAvailable() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v2"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + + [Fact] + public void TryCreateOrchestrator_WithMixedRegistrations_PrefersExactVersionMatch() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v1"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + + [Fact] + public void TryCreateOrchestrator_WithMixedRegistrations_UsesUnversionedFallbackForUnknownVersion() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v3"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + + [Fact] + public void PublicTryCreateOrchestrator_UsesUnversionedRegistrationOnly() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = factory.TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + + [DurableTask("InvoiceWorkflow")] + [DurableTaskVersion("v1")] + sealed class InvoiceWorkflowV1 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult("v1"); + } + + [DurableTask("InvoiceWorkflow")] + [DurableTaskVersion("v2")] + sealed class InvoiceWorkflowV2 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult("v2"); + } + + [DurableTask("InvoiceWorkflow")] + sealed class UnversionedInvoiceWorkflow : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult("unversioned"); + } +} diff --git a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs index 06df43170..4a2b91de7 100644 --- a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs +++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Generic; +using System.Reflection; using DurableTask.Core; using Microsoft.Extensions.Logging.Abstractions; @@ -103,21 +105,199 @@ public void ContinueAsNew_WithOptionsNoVersion_CallsInnerContextWithoutVersion() innerContext.LastContinueAsNewVersion.Should().BeNull(); } + [Fact] + public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrchestrationVersion() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallActivityAsync( + "TestActivity", + 123, + new ActivityOptions + { + Version = "v1", + }); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v1"); + innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().Contain( + ActivityVersioning.ExplicitVersionTagName, + bool.TrueString); + } + + [Fact] + public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrchestrationVersion_WithRetryPolicy() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallActivityAsync( + "TestActivity", + 123, + new ActivityOptions(new RetryPolicy(1, TimeSpan.FromSeconds(1))) + { + Version = "v1", + }); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v1"); + innerContext.LastScheduledTaskInput.Should().Be(123); + } + + [Fact] + public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrchestrationVersion_WithRetryHandler() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + ActivityOptions options = new(TaskOptions.FromRetryHandler(_ => false)) + { + Version = "v1", + }; + + // Act + await wrapper.CallActivityAsync("TestActivity", 123, options); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v1"); + innerContext.LastScheduledTaskInput.Should().Be(123); + } + + [Fact] + public async Task CallActivityAsync_PlainTaskOptionsUsesInheritedOrchestrationVersion() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallActivityAsync("TestActivity", 123, new TaskOptions()); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v2"); + innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); + } + + [Fact] + public async Task CallActivityAsync_UserSuppliedReservedExplicitVersionTagIsIgnoredWhenVersionIsInherited() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallActivityAsync( + "TestActivity", + 123, + new TaskOptions(tags: new Dictionary + { + [ActivityVersioning.ExplicitVersionTagName] = bool.FalseString, + })); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v2"); + innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); + } + + [Fact] + public async Task CallActivityAsync_NullOptionsUsesInheritedOrchestrationVersion() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallActivityAsync("TestActivity", 123); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v2"); + innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); + } + + [Theory] + [InlineData(false, null)] + [InlineData(true, null)] + [InlineData(true, "")] + [InlineData(true, " ")] + public async Task CallActivityAsync_MissingOrEmptyActivityVersionUsesInheritedOrchestrationVersion( + bool setVersion, + string? explicitVersion) + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + ActivityOptions options = new(); + + if (setVersion) + { + options = options with + { + Version = explicitVersion is null ? default(TaskVersion?) : new TaskVersion(explicitVersion), + }; + } + + // Act + await wrapper.CallActivityAsync("TestActivity", 123, options); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v2"); + innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); + } + + static IReadOnlyDictionary GetLastScheduledTaskTags(TrackingOrchestrationContext innerContext) + { + PropertyInfo tagsProperty = innerContext.LastScheduledTaskOptions!.GetType().GetProperty("Tags")!; + return (IReadOnlyDictionary)tagsProperty.GetValue(innerContext.LastScheduledTaskOptions)!; + } + sealed class TrackingOrchestrationContext : OrchestrationContext { - public TrackingOrchestrationContext() + public TrackingOrchestrationContext(string? version = null) { this.OrchestrationInstance = new() { InstanceId = Guid.NewGuid().ToString(), ExecutionId = Guid.NewGuid().ToString(), }; + this.Version = version ?? string.Empty; } public object? LastContinueAsNewInput { get; private set; } public string? LastContinueAsNewVersion { get; private set; } + public string? LastScheduledTaskName { get; private set; } + + public string? LastScheduledTaskVersion { get; private set; } + + public object? LastScheduledTaskInput { get; private set; } + + public ScheduleTaskOptions? LastScheduledTaskOptions { get; private set; } + public override void ContinueAsNew(object input) { this.LastContinueAsNewInput = input; @@ -146,7 +326,33 @@ public override Task CreateTimer(DateTime fireAt, T state, CancellationTok => throw new NotImplementedException(); public override Task ScheduleTask(string name, string version, params object[] parameters) - => throw new NotImplementedException(); + => this.CaptureScheduledTask(name, version, parameters); + + public override Task ScheduleTask( + string name, + string version, + ScheduleTaskOptions options, + params object[] parameters) + => this.CaptureScheduledTask(name, version, parameters, options); + + Task CaptureScheduledTask( + string name, + string version, + object[] parameters, + ScheduleTaskOptions? options = null) + { + this.LastScheduledTaskName = name; + this.LastScheduledTaskVersion = version; + this.LastScheduledTaskInput = parameters.Length switch + { + 0 => null, + 1 => parameters[0], + _ => parameters, + }; + this.LastScheduledTaskOptions = options; + + return Task.FromResult(default(TResult)!); + } public override void SendEvent(OrchestrationInstance orchestrationInstance, string eventName, object eventData) => throw new NotImplementedException(); @@ -210,4 +416,4 @@ public override void SendEvent(OrchestrationInstance orchestrationInstance, stri throw new NotImplementedException(); } } -} \ No newline at end of file +}