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..edfa34808 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. + +> Per-class `[DurableTaskVersion]` routing composes with `DurableTaskWorkerOptions.Versioning` (or `UseVersioning(...)`). The worker-level `MatchStrategy` gates which instance versions are accepted off the wire, and the registry then dispatches each accepted work item to the implementation that exactly matches its `(name, version)`. Use them together when a single worker needs to host multiple versions of the same name. +> +> 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..995e1ab00 --- /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.ScheduleNewCheckoutWorkflowV1InstanceAsync(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.ScheduleNewCheckoutWorkflowV2InstanceAsync(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.CallShippingQuoteV1Async(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..f9a628ece --- /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 `CallShippingQuoteV1Async()` and `CallShippingQuoteV2Async()` 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..f63d473dc --- /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 ScheduleNewOrderWorkflowV1InstanceAsync automatically +// stamps the instance with version "1". +Console.WriteLine("Scheduling OrderWorkflow v1 ..."); +string v1Id = await client.ScheduleNewOrderWorkflowV1InstanceAsync(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.ScheduleNewOrderWorkflowV2InstanceAsync(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.ScheduleNewMigratingWorkflowV1InstanceAsync(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..81dddeda9 --- /dev/null +++ b/samples/PerOrchestratorVersioningSample/README.md @@ -0,0 +1,72 @@ +# 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 `ScheduleNewOrderWorkflowV1InstanceAsync()` and `ScheduleNewOrderWorkflowV2InstanceAsync()` +- `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. + +Scheduling MigratingWorkflow v1 → v2 (ContinueAsNew migration) ... + Result: Migrated order total: $80 (v2 — after migration from v1) + +Sample completed successfully! +``` + +### 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:** `[DurableTaskVersion]` routing and worker-level `UseVersioning()` now compose. `UseVersioning()` filters which instance versions the worker accepts (via `MatchStrategy`), and the per-class registry dispatches each accepted work item to the implementation that exactly matches its `(name, version)`. Use them together if you need both deployment-level gating and same-process multi-version routing. diff --git a/samples/WorkerVersioningSample/Program.cs b/samples/WorkerVersioningSample/Program.cs new file mode 100644 index 000000000..c8b82649c --- /dev/null +++ b/samples/WorkerVersioningSample/Program.cs @@ -0,0 +1,86 @@ +// 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 UseVersioning(). The worker only accepts orchestration +// instances stamped with the same version, and the client stamps new instances with +// that version via UseDefaultVersion(). To upgrade, you deploy a new worker binary +// with the updated implementation and version string. +// +// This sample registers a single orchestration ("GreetingWorkflow") and shows how +// both the client-side stamping and the worker-side acceptance filter are tied to +// the same WORKER_VERSION value. + +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. UseVersioning pins the worker to a single version: with +// MatchStrategy = Strict the worker only processes instances whose version matches. +builder.Services.AddDurableTaskWorker(wb => +{ + wb.AddTasks(tasks => + { + tasks.AddOrchestratorFunc( + "GreetingWorkflow", + (ctx, name) => Task.FromResult($"Hello, {name}! (worker version: {ctx.Version})")); + }); + + wb.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions + { + Version = workerVersion, + DefaultVersion = workerVersion, + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + FailureStrategy = DurableTaskWorkerOptions.VersionFailureStrategy.Reject, + }); + + 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 +// and accepted by the worker because both are configured with the same WORKER_VERSION. +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..d85e66b96 --- /dev/null +++ b/samples/WorkerVersioningSample/README.md @@ -0,0 +1,57 @@ +# 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 worker uses `UseVersioning(...)` with `MatchStrategy = Strict` to pin itself to a single version and reject orchestration instances stamped with a different version. +- The client uses `UseDefaultVersion()` to stamp every new orchestration instance with the same version automatically. +- 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/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..3b539afb1 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); + + TaskVersionKey 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..c500e8971 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)) + + TaskVersionKey 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..5742e7b28 --- /dev/null +++ b/src/Abstractions/DurableTaskVersionAttribute.cs @@ -0,0 +1,31 @@ +// 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) + { + // TaskVersion's constructor itself rejects whitespace-only strings and normalizes null/empty to + // TaskVersion.Unversioned, so a single delegation here covers all three cases. + this.Version = string.IsNullOrEmpty(version) ? TaskVersion.Unversioned : new TaskVersion(version!); + } + + /// + /// Gets the durable task version declared on the attributed class. + /// + public TaskVersion Version { get; } +} diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index 7c0d54ee2..ff490b30d 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -1,209 +1,216 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Collections.Immutable; - -namespace Microsoft.DurableTask; - -/// -/// Options that can be used to control the behavior of orchestrator task execution. -/// -public record TaskOptions -{ - /// - /// Initializes a new instance of the class. - /// - /// The task retry options. - public TaskOptions(TaskRetryOptions? retry) - : this(retry, null) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The task retry options. - /// The tags to associate with the task. - public TaskOptions(TaskRetryOptions? retry = null, IDictionary? tags = null) - { - this.Retry = retry; - this.Tags = tags; - } - - /// - /// Initializes a new instance of the class by copying from another instance. - /// - /// The task options to copy from. - public TaskOptions(TaskOptions options) - { - Check.NotNull(options); - this.Retry = options.Retry; - this.Tags = options.Tags; - } - - /// - /// Gets the task retry options. - /// - public TaskRetryOptions? Retry { get; init; } - - /// - /// Gets the tags to associate with the task. - /// - public IDictionary? Tags { get; init; } - - /// - /// Returns a new from the provided . - /// - /// The policy to convert from. - /// A built from the policy. - public static TaskOptions FromRetryPolicy(RetryPolicy policy) => new(policy); - - /// - /// Returns a new from the provided . - /// - /// The handler to convert from. - /// A built from the handler. - public static TaskOptions FromRetryHandler(AsyncRetryHandler handler) => new(handler); - - /// - /// Returns a new from the provided . - /// - /// The handler to convert from. - /// A built from the handler. - public static TaskOptions FromRetryHandler(RetryHandler handler) => new(handler); - - /// - /// Returns a new with the provided instance ID. This can be used when - /// 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 -{ - /// - /// Initializes a new instance of the class. - /// - /// The task retry options. - /// The orchestration instance ID. - public SubOrchestrationOptions(TaskRetryOptions? retry = null, string? instanceId = null) - : base(retry) - { - this.InstanceId = instanceId; - } - - /// - /// Initializes a new instance of the class. - /// - /// The task options to wrap. - /// The orchestration instance ID. - public SubOrchestrationOptions(TaskOptions options, string? instanceId = null) - : base(options) - { - this.InstanceId = instanceId; - if (options is SubOrchestrationOptions derived) - { - if (instanceId is null) - { - this.InstanceId = derived.InstanceId; - } - - this.Version = derived.Version; - } - } - - /// - /// Initializes a new instance of the class by copying from another instance. - /// - /// The sub-orchestration options to copy from. - public SubOrchestrationOptions(SubOrchestrationOptions options) - : base(options) - { - Check.NotNull(options); - this.InstanceId = options.InstanceId; - this.Version = options.Version; - } - - /// - /// Gets the orchestration instance ID. - /// - public string? InstanceId { get; init; } - - /// - /// Gets the version to associate with the sub-orchestration instance. - /// - public TaskVersion? Version { get; init; } -} - -/// -/// Options for submitting new orchestrations via the client. -/// -public record StartOrchestrationOptions -{ - /// - /// Initializes a new instance of the class. - /// - /// - /// The unique ID of the orchestration instance to schedule. If not specified, a new GUID value is used. - /// - /// - /// The time when the orchestration instance should start executing. If not specified or if a date-time in the past - /// is specified, the orchestration instance will be scheduled immediately. - /// -#pragma warning disable SA1313 // Parameter names should begin with lower-case letter - using PascalCase to maintain backward compatibility with positional record syntax - public StartOrchestrationOptions(string? InstanceId = null, DateTimeOffset? StartAt = null) -#pragma warning restore SA1313 - { - this.InstanceId = InstanceId; - this.StartAt = StartAt; - } - - /// - /// Initializes a new instance of the class by copying from another instance. - /// - /// The start orchestration options to copy from. - public StartOrchestrationOptions(StartOrchestrationOptions options) - { - Check.NotNull(options); - this.InstanceId = options.InstanceId; - this.StartAt = options.StartAt; - this.Tags = options.Tags; - this.Version = options.Version; - this.DedupeStatuses = options.DedupeStatuses; - } - - /// - /// Gets the unique ID of the orchestration instance to schedule. If not specified, a new GUID value is used. - /// - public string? InstanceId { get; init; } - - /// - /// Gets the time when the orchestration instance should start executing. If not specified or if a date-time in the past - /// is specified, the orchestration instance will be scheduled immediately. - /// - public DateTimeOffset? StartAt { get; init; } - - /// - /// Gets the tags to associate with the orchestration instance. - /// - public IReadOnlyDictionary Tags { get; init; } = ImmutableDictionary.Create(); - - /// - /// Gets the version to associate with the orchestration instance. - /// - public TaskVersion? Version { get; init; } - - /// - /// Gets the orchestration runtime statuses that should be considered for deduplication. - /// - /// - /// For type-safe usage, use the WithDedupeStatuses extension method. - /// - public IReadOnlyList? DedupeStatuses { get; init; } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; + +namespace Microsoft.DurableTask; + +/// +/// Options that can be used to control the behavior of orchestrator task execution. +/// +public record TaskOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The task retry options. + public TaskOptions(TaskRetryOptions? retry) + : this(retry, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The task retry options. + /// The tags to associate with the task. + public TaskOptions(TaskRetryOptions? retry = null, IDictionary? tags = null) + { + this.Retry = retry; + this.Tags = tags; + } + + /// + /// Initializes a new instance of the class by copying from another instance. + /// + /// The task options to copy from. + public TaskOptions(TaskOptions options) + { + Check.NotNull(options); + this.Retry = options.Retry; + this.Tags = options.Tags; + this.Version = options.Version; + } + + /// + /// Gets the task retry options. + /// + public TaskRetryOptions? Retry { get; init; } + + /// + /// Gets the tags to associate with the task. + /// + public IDictionary? Tags { get; init; } + + /// + /// Gets the version to associate with the scheduled task. + /// + /// + /// + /// When null (the default), the task inherits the version of the orchestration instance that is + /// scheduling it. + /// + /// + /// When non-null (including ), the task is scheduled with the + /// specified version explicitly. The worker dispatches to the registered (name, version) exactly; + /// when no exact match exists, it falls back to an unversioned registration only when the name has no + /// versioned registrations at all. + /// + /// + public TaskVersion? Version { get; init; } + + /// + /// Returns a new from the provided . + /// + /// The policy to convert from. + /// A built from the policy. + public static TaskOptions FromRetryPolicy(RetryPolicy policy) => new(policy); + + /// + /// Returns a new from the provided . + /// + /// The handler to convert from. + /// A built from the handler. + public static TaskOptions FromRetryHandler(AsyncRetryHandler handler) => new(handler); + + /// + /// Returns a new from the provided . + /// + /// The handler to convert from. + /// A built from the handler. + public static TaskOptions FromRetryHandler(RetryHandler handler) => new(handler); + + /// + /// Returns a new with the provided instance ID. This can be used when + /// 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 +{ + /// + /// Initializes a new instance of the class. + /// + /// The task retry options. + /// The orchestration instance ID. + public SubOrchestrationOptions(TaskRetryOptions? retry = null, string? instanceId = null) + : base(retry) + { + this.InstanceId = instanceId; + } + + /// + /// Initializes a new instance of the class. + /// + /// The task options to wrap. + /// The orchestration instance ID. + public SubOrchestrationOptions(TaskOptions options, string? instanceId = null) + : base(options) + { + this.InstanceId = instanceId; + if (options is SubOrchestrationOptions derived && instanceId is null) + { + this.InstanceId = derived.InstanceId; + } + } + + /// + /// Initializes a new instance of the class by copying from another instance. + /// + /// The sub-orchestration options to copy from. + public SubOrchestrationOptions(SubOrchestrationOptions options) + : base(options) + { + Check.NotNull(options); + this.InstanceId = options.InstanceId; + } + + /// + /// Gets the orchestration instance ID. + /// + public string? InstanceId { get; init; } +} + +/// +/// Options for submitting new orchestrations via the client. +/// +public record StartOrchestrationOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The unique ID of the orchestration instance to schedule. If not specified, a new GUID value is used. + /// + /// + /// The time when the orchestration instance should start executing. If not specified or if a date-time in the past + /// is specified, the orchestration instance will be scheduled immediately. + /// +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter - using PascalCase to maintain backward compatibility with positional record syntax + public StartOrchestrationOptions(string? InstanceId = null, DateTimeOffset? StartAt = null) +#pragma warning restore SA1313 + { + this.InstanceId = InstanceId; + this.StartAt = StartAt; + } + + /// + /// Initializes a new instance of the class by copying from another instance. + /// + /// The start orchestration options to copy from. + public StartOrchestrationOptions(StartOrchestrationOptions options) + { + Check.NotNull(options); + this.InstanceId = options.InstanceId; + this.StartAt = options.StartAt; + this.Tags = options.Tags; + this.Version = options.Version; + this.DedupeStatuses = options.DedupeStatuses; + } + + /// + /// Gets the unique ID of the orchestration instance to schedule. If not specified, a new GUID value is used. + /// + public string? InstanceId { get; init; } + + /// + /// Gets the time when the orchestration instance should start executing. If not specified or if a date-time in the past + /// is specified, the orchestration instance will be scheduled immediately. + /// + public DateTimeOffset? StartAt { get; init; } + + /// + /// Gets the tags to associate with the orchestration instance. + /// + public IReadOnlyDictionary Tags { get; init; } = ImmutableDictionary.Create(); + + /// + /// Gets the version to associate with the orchestration instance. + /// + public TaskVersion? Version { get; init; } + + /// + /// Gets the orchestration runtime statuses that should be considered for deduplication. + /// + /// + /// For type-safe usage, use the WithDedupeStatuses extension method. + /// + public IReadOnlyList? DedupeStatuses { get; init; } +} diff --git a/src/Abstractions/TaskVersion.cs b/src/Abstractions/TaskVersion.cs index 5d6d1bfec..fbdb13bc2 100644 --- a/src/Abstractions/TaskVersion.cs +++ b/src/Abstractions/TaskVersion.cs @@ -8,26 +8,54 @@ namespace Microsoft.DurableTask; /// public readonly struct TaskVersion : IEquatable { + /// + /// A sentinel value representing an unversioned task. Equivalent to default(TaskVersion) and + /// new TaskVersion(string.Empty). + /// + /// + /// Use this on to explicitly request the unversioned task + /// implementation from a versioned orchestration. null on the same property means the activity + /// inherits the orchestration instance version. + /// + public static readonly TaskVersion Unversioned = default; + + readonly string? versionValue; + /// /// Initializes a new instance of the struct. /// - /// The version of the task. Providing null will result in the default struct. + /// The version of the task. null or produces + /// an unversioned equal to . + /// + /// Thrown when is non-empty but contains only whitespace. Pass null, + /// , or use to represent an unversioned task. + /// public TaskVersion(string version) { - if (version == null) + // Normalize null/empty to string.Empty so default(TaskVersion), TaskVersion.Unversioned, and + // new TaskVersion("") all compare and hash identically. The Version getter additionally coalesces + // for default-constructed structs (which skip this constructor and zero-initialize the field). + if (string.IsNullOrEmpty(version)) { - this.Version = null!; + this.versionValue = string.Empty; + return; } - else + + if (string.IsNullOrWhiteSpace(version)) { - this.Version = version; + throw new ArgumentException( + "Version must not be whitespace-only. Pass null, an empty string, or use TaskVersion.Unversioned to represent an unversioned task.", + nameof(version)); } + + this.versionValue = version; } /// - /// Gets the version of a task. + /// Gets the version of a task. Returns for an unversioned task, including + /// default(TaskVersion) and ; never returns null. /// - public string Version { get; } + public string Version => this.versionValue ?? string.Empty; /// /// Implicitly converts a into a of the property value. @@ -71,7 +99,12 @@ public TaskVersion(string version) /// true if the two are equal using value semantics; otherwise false. public bool Equals(TaskVersion other) { - return string.Equals(this.Version, other.Version, StringComparison.OrdinalIgnoreCase); + // Treat null and empty Version as the same unversioned identity. Combined with normalization in + // the constructor, both default(TaskVersion) and new TaskVersion("") compare equal and hash to + // the same value as TaskVersion.Unversioned. + string left = this.Version ?? string.Empty; + string right = other.Version ?? string.Empty; + return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); } /// @@ -96,6 +129,9 @@ public override bool Equals(object? obj) /// A 32-bit hash code value. public override int GetHashCode() { - return StringComparer.OrdinalIgnoreCase.GetHashCode(this.Version); + // Null-safe: a default-constructed TaskVersion (or one created via the implicit conversion from + // null) must not crash when used as a dictionary key. Treats null and empty as the same key. + string value = this.Version ?? string.Empty; + return StringComparer.OrdinalIgnoreCase.GetHashCode(value); } } diff --git a/src/Abstractions/TaskVersionKey.cs b/src/Abstractions/TaskVersionKey.cs new file mode 100644 index 000000000..29d643892 --- /dev/null +++ b/src/Abstractions/TaskVersionKey.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 or activity. +/// +internal readonly struct TaskVersionKey : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The task name. + /// The task version. + public TaskVersionKey(TaskName name, TaskVersion version) + : this(name.Name, version.Version) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The task name. + /// The task version. + public TaskVersionKey(string name, string? version) + { + this.Name = Check.NotNullOrEmpty(name, nameof(name)); + this.Version = version ?? string.Empty; + } + + /// + /// Gets the logical task name. + /// + public string Name { get; } + + /// + /// Gets the task 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(TaskVersionKey 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 TaskVersionKey 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/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..0c83a0100 100644 --- a/src/Generators/AnalyzerReleases.Unshipped.md +++ b/src/Generators/AnalyzerReleases.Unshipped.md @@ -7,3 +7,6 @@ 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. +DURABLE3005 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when a [DurableTaskVersion] attribute is constructed with a whitespace-only argument. Provide a non-empty version or omit the attribute. 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..a41d62981 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,21 @@ 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"; + + /// + /// Diagnostic ID for whitespace-only [DurableTaskVersion] arguments. + /// + const string WhitespaceTaskVersionDiagnosticId = "DURABLE3005"; + static readonly DiagnosticDescriptor InvalidTaskNameRule = new( InvalidTaskNameDiagnosticId, title: "Invalid task name", @@ -66,6 +83,30 @@ 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); + + static readonly DiagnosticDescriptor WhitespaceTaskVersionRule = new( + WhitespaceTaskVersionDiagnosticId, + title: "Whitespace-only [DurableTaskVersion] argument", + messageFormat: "The [DurableTaskVersion] argument on '{0}' must not be whitespace-only. Provide a non-empty version string or omit the attribute argument to declare an unversioned task.", + category: "DurableTask.Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + /// public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -214,7 +255,33 @@ public void Initialize(IncrementalGeneratorInitializationContext context) taskNameLocation = expression.GetLocation(); } - return new DurableTaskTypeInfo(className, classNamespace, taskName, inputType, outputType, kind, taskNameLocation); + string taskVersion = string.Empty; + Location? taskVersionLocation = null; + bool hasWhitespaceVersion = false; + foreach (AttributeData attributeData in classType.GetAttributes() + .Where(a => a.AttributeClass?.ToDisplayString() == "Microsoft.DurableTask.DurableTaskVersionAttribute")) + { + if (attributeData.ConstructorArguments.Length > 0 + && attributeData.ConstructorArguments[0].Value is string version) + { + if (version.Length > 0 && string.IsNullOrWhiteSpace(version)) + { + hasWhitespaceVersion = true; + taskVersionLocation = attributeData.ApplicationSyntaxReference?.GetSyntax().GetLocation(); + // Treat as unversioned for downstream emission so we don't generate code referencing + // a whitespace literal; the diagnostic below will fail the build. + taskVersion = string.Empty; + } + else + { + taskVersion = version; + } + + break; + } + } + + return new DurableTaskTypeInfo(className, classNamespace, taskName, inputType, outputType, kind, taskVersion, taskNameLocation, taskVersionLocation, hasWhitespaceVersion); } static DurableEventTypeInfo? GetDurableEventTypeInfo(GeneratorSyntaxContext context) @@ -338,10 +405,40 @@ static void Execute( IEnumerable validTasks = allTasks .Where(task => IsValidCSharpIdentifier(task.TaskName)); + // Surface whitespace-only [DurableTaskVersion] as an error before we partition by name+version. + foreach (DurableTaskTypeInfo task in allTasks.Where(t => t.HasWhitespaceVersion)) + { + Location location = task.TaskVersionLocation ?? task.TaskNameLocation ?? Location.None; + context.ReportDiagnostic(Diagnostic.Create( + WhitespaceTaskVersionRule, + location, + 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 +447,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 +639,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 +674,17 @@ public static class GeneratedDurableTaskExtensions AddOrchestratorFunctionDeclaration(sourceBuilder, orchestrator, targetNamespace); } - AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace); - AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace); + string helperRoot = GetStandaloneHelperRoot(orchestrator, isDurableFunctions, standaloneOrchestratorCountsByTaskName); + bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(orchestrator.TaskVersion); + AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperRoot, applyGeneratedVersion); + AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperRoot, applyGeneratedVersion); } foreach (DurableTaskTypeInfo activity in activitiesInNs) { - AddActivityCallMethod(sourceBuilder, activity, targetNamespace); + string helperRoot = GetStandaloneHelperRoot(activity, isDurableFunctions, standaloneActivityCountsByTaskName); + bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(activity.TaskVersion); + AddActivityCallMethod(sourceBuilder, activity, targetNamespace, helperRoot, applyGeneratedVersion); if (isDurableFunctions) { @@ -499,6 +692,14 @@ public static class GeneratedDurableTaskExtensions } } + if (hasVersionedStandaloneHelpers) + { + AddStandaloneGeneratedVersionHelperMethods( + sourceBuilder, + hasVersionedStandaloneOrchestratorHelpers, + hasVersionedStandaloneActivityHelpers); + } + foreach (DurableTaskTypeInfo entity in entitiesInNs) { if (isDurableFunctions) @@ -612,6 +813,44 @@ static string SimplifyTypeName(string fullyQualifiedTypeName, string targetNames return fullyQualifiedTypeName; } + static string GetStandaloneHelperRoot(DurableTaskTypeInfo task, bool isDurableFunctions, Dictionary standaloneTaskCountsByTaskName) + { + // When a logical task name has more than one class-based registration in the same standalone + // project, the generator emits one helper per class and derives the helper-name root from the + // class's simple name rather than from the (shared) durable task name. This keeps every + // generated helper unique without encoding the version into the method name. Single-class and + // Azure Functions cases continue to use the durable task name unchanged. + if (isDurableFunctions + || string.IsNullOrEmpty(task.TaskVersion) + || !standaloneTaskCountsByTaskName.TryGetValue(task.TaskName, out int count) + || count <= 1) + { + return task.TaskName; + } + + return GetClassRelativeName(task.TypeName, task.Namespace); + } + + static string GetClassRelativeName(string fullyQualifiedTypeName, string classNamespace) + { + // Strips the containing-namespace prefix and joins any outer-class chain with '_' so the + // result is a valid C# identifier. For "MyApp.OrderWorkflowV2" (top-level) returns + // "OrderWorkflowV2"; for "MyApp.Outer.Inner" (nested) returns "Outer_Inner". This makes + // the derived helper-name root unique across nested-type pairs that share a simple name. + string relative = !string.IsNullOrEmpty(classNamespace) + && fullyQualifiedTypeName.StartsWith(classNamespace + ".", StringComparison.Ordinal) + ? fullyQualifiedTypeName.Substring(classNamespace.Length + 1) + : fullyQualifiedTypeName; + return relative.Replace('.', '_'); + } + + static string GetStandaloneTaskRegistrationKey(string taskName, string taskVersion) + { + return string.Concat(taskName, "\0", taskVersion); + } + + 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 +865,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 helperRoot, bool applyGeneratedVersion) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); string inputParameter = inputType + " input"; @@ -636,20 +875,27 @@ static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTy } string simplifiedTypeName = SimplifyTypeName(orchestrator.TypeName, targetNamespace); + string optionsExpression = applyGeneratedVersion + ? $"ApplyGeneratedVersion(options, {ToCSharpStringLiteral(orchestrator.TaskVersion)})" + : "options"; + string versionRemarks = applyGeneratedVersion + ? $@" + /// Stamps version {orchestrator.TaskVersion} on the started instance. A non-null .Version overrides this baked version." + : string.Empty; sourceBuilder.AppendLine($@" /// /// Schedules a new instance of the orchestrator. - /// + /// {versionRemarks} /// - public static Task ScheduleNew{orchestrator.TaskName}InstanceAsync( + public static Task ScheduleNew{helperRoot}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 helperRoot, bool applyGeneratedVersion) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); string outputType = orchestrator.GetOutputTypeForNamespace(targetNamespace); @@ -660,20 +906,102 @@ static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTas } string simplifiedTypeName = SimplifyTypeName(orchestrator.TypeName, targetNamespace); + string optionsExpression = applyGeneratedVersion + ? $"ApplyGeneratedVersion(options, {ToCSharpStringLiteral(orchestrator.TaskVersion)})" + : "options"; + string versionRemarks = applyGeneratedVersion + ? $@" + /// Stamps version {orchestrator.TaskVersion} on the sub-orchestration. A non-null .Version overrides this baked version." + : string.Empty; sourceBuilder.AppendLine($@" /// /// Calls the sub-orchestrator. - /// + /// {versionRemarks} /// - public static Task<{outputType}> Call{orchestrator.TaskName}Async( + public static Task<{outputType}> Call{helperRoot}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) + { + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + if (options.Version is not null) + { + return options; + } + + return options with { Version = new TaskVersion(version) }; + } + + static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) + { + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : new SubOrchestrationOptions(options) { Version = version }; + }"); + } + + if (includeActivityVersionHelpers) + { + sourceBuilder.AppendLine(@" + static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) + { + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new TaskOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : new TaskOptions(options) { Version = version }; + }"); + } + } + + static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace, string helperRoot, bool applyGeneratedVersion) { string inputType = activity.GetInputTypeForNamespace(targetNamespace); string outputType = activity.GetOutputTypeForNamespace(targetNamespace); @@ -684,15 +1012,22 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeIn } string simplifiedTypeName = SimplifyTypeName(activity.TypeName, targetNamespace); + string optionsExpression = applyGeneratedVersion + ? $"ApplyGeneratedActivityVersion(options, {ToCSharpStringLiteral(activity.TaskVersion)})" + : "options"; + string versionRemarks = applyGeneratedVersion + ? $@" + /// Stamps version {activity.TaskVersion} on the activity call. A non-null .Version overrides this baked version." + : string.Empty; sourceBuilder.AppendLine($@" /// /// Calls the activity. - /// + /// {versionRemarks} /// - public static Task<{outputType}> Call{activity.TaskName}Async(this TaskOrchestrationContext ctx, {inputParameter}, TaskOptions? options = null) + public static Task<{outputType}> Call{helperRoot}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,13 +1203,19 @@ public DurableTaskTypeInfo( ITypeSymbol? inputType, ITypeSymbol? outputType, DurableTaskKind kind, - Location? taskNameLocation = null) + string taskVersion, + Location? taskNameLocation = null, + Location? taskVersionLocation = null, + bool hasWhitespaceVersion = false) { this.TypeName = taskType; this.Namespace = taskNamespace; this.TaskName = taskName; this.Kind = kind; + this.TaskVersion = taskVersion; this.TaskNameLocation = taskNameLocation; + this.TaskVersionLocation = taskVersionLocation; + this.HasWhitespaceVersion = hasWhitespaceVersion; this.InputTypeSymbol = inputType; this.OutputTypeSymbol = outputType; } @@ -882,8 +1223,11 @@ 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; } + public Location? TaskVersionLocation { get; } + public bool HasWhitespaceVersion { get; } ITypeSymbol? InputTypeSymbol { get; } ITypeSymbol? OutputTypeSymbol { get; } diff --git a/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs b/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs index f6bafb450..46f85a114 100644 --- a/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs +++ b/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs @@ -310,6 +310,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 a2c424757..adff81693 100644 --- a/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs +++ b/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs @@ -856,7 +856,7 @@ async Task ITaskExecutor.ExecuteActivity(OrchestrationI try { - await this.SendWorkItemToClientAsync(new P.WorkItem + P.WorkItem workItem = new() { ActivityRequest = new P.ActivityRequest { @@ -877,7 +877,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/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs index c8eefb12c..8e4f7428a 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs @@ -49,12 +49,19 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil DurableTaskRegistry registry = this.registryMonitor.Get(name); + HashSet registeredOrchestratorNames = new( + registry.Orchestrators.Keys.Select(k => k.Name), + StringComparer.OrdinalIgnoreCase); + HashSet registeredActivityNames = new( + registry.Activities.Keys.Select(k => k.Name), + StringComparer.OrdinalIgnoreCase); + List unknownOrchestrations = FindUnknown( - options.Orchestrations.Select(o => o.Name), n => registry.Orchestrators.ContainsKey(n)); + options.Orchestrations.Select(o => o.Name), registeredOrchestratorNames.Contains); List unknownActivities = FindUnknown( - options.Activities.Select(a => a.Name), n => registry.Activities.ContainsKey(n)); + options.Activities.Select(a => a.Name), registeredActivityNames.Contains); List unknownEntities = FindUnknown( - options.Entities.Select(e => e.Name), n => registry.Entities.ContainsKey(n)); + options.Entities.Select(e => e.Name), n => registry.Entities.ContainsKey(new TaskName(n))); if (unknownOrchestrations.Count == 0 && unknownActivities.Count == 0 @@ -76,7 +83,7 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil return ValidateOptionsResult.Fail(sb.ToString()); } - static List FindUnknown(IEnumerable names, Func isRegistered) + static List FindUnknown(IEnumerable names, Func isRegistered) { List unknown = []; foreach (string name in names) @@ -87,10 +94,7 @@ static List FindUnknown(IEnumerable names, Func continue; } - // TaskName equality is OrdinalIgnoreCase, mirroring how registered keys are compared. - // Construct the TaskName explicitly so the conversion is not dependent on the implicit - // string -> TaskName operator (which could be removed/changed independently). - if (!isRegistered(new TaskName(name))) + if (!isRegistered(name)) { unknown.Add(name); } diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 0e77a584a..fcc97800f 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -9,11 +9,13 @@ namespace Microsoft.DurableTask.Worker; /// /// A factory for creating orchestrators and activities. /// -sealed class DurableTaskFactory : IDurableTaskFactory2 +sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedTaskFactory { - readonly IDictionary> activities; - readonly IDictionary> orchestrators; + readonly IDictionary> activities; + readonly IDictionary> orchestrators; readonly IDictionary> entities; + readonly HashSet versionedOrchestratorNames; + readonly HashSet versionedActivityNames; /// /// Initializes a new instance of the class. @@ -22,21 +24,52 @@ 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); this.orchestrators = Check.NotNull(orchestrators); this.entities = Check.NotNull(entities); + + // Snapshot the set of logical names that have at least one versioned registration. Used to gate the + // unversioned-fallback path: when a logical name has any versioned registration, we refuse to fall + // back to its unversioned registration for an unmatched versioned request — that would silently + // route the call to a different implementation than the caller asked for. + this.versionedOrchestratorNames = new HashSet( + this.orchestrators.Keys + .Where(k => !string.IsNullOrWhiteSpace(k.Version)) + .Select(k => k.Name), + StringComparer.OrdinalIgnoreCase); + this.versionedActivityNames = new HashSet( + this.activities.Keys + .Where(k => !string.IsNullOrWhiteSpace(k.Version)) + .Select(k => k.Name), + StringComparer.OrdinalIgnoreCase); } /// public bool TryCreateActivity( - TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskActivity? activity) + TaskName name, + TaskVersion version, + IServiceProvider serviceProvider, + [NotNullWhen(true)] out ITaskActivity? activity) { Check.NotNull(serviceProvider); - if (this.activities.TryGetValue(name, out Func? factory)) + TaskVersionKey key = new(name, version); + if (this.activities.TryGetValue(key, out Func? factory)) + { + activity = factory.Invoke(serviceProvider); + return true; + } + + // Unversioned registrations remain the compatibility fallback for a versioned request, but ONLY when + // no versioned registration exists for the same logical name. This mirrors the orchestrator rule: + // once a name has any versioned registration, an unmatched versioned request returns "not found" + // rather than silently routing to a catch-all the caller did not ask for. + if (!string.IsNullOrWhiteSpace(version.Version) + && !this.versionedActivityNames.Contains(name.Name) + && this.activities.TryGetValue(new TaskVersionKey(name, default(TaskVersion)), out factory)) { activity = factory.Invoke(serviceProvider); return true; @@ -46,11 +79,33 @@ public bool TryCreateActivity( return false; } + /// + public bool TryCreateActivity( + TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskActivity? activity) + => this.TryCreateActivity(name, default(TaskVersion), serviceProvider, 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); + TaskVersionKey key = new(name, version); + if (this.orchestrators.TryGetValue(key, out Func? factory)) + { + orchestrator = factory.Invoke(serviceProvider); + return true; + } + + // Unversioned registrations remain the compatibility fallback for a versioned request, but ONLY when + // no versioned registration exists for the same logical name. If any versioned registration is present + // (e.g., v1 and v2 are registered, request asks for v3), we refuse to silently route the call to a + // catch-all registration the caller did not ask for. + if (!string.IsNullOrWhiteSpace(version.Version) + && !this.versionedOrchestratorNames.Contains(name.Name) + && this.orchestrators.TryGetValue(new TaskVersionKey(name, default(TaskVersion)), out factory)) { orchestrator = factory.Invoke(serviceProvider); return true; @@ -60,6 +115,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..a2de774f0 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -35,32 +35,69 @@ 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]; - } + // Under MatchStrategy.Strict the worker accepts only instances whose version matches the + // worker's configured Version exactly — including the empty/unversioned case. The filter must + // narrow each name's version list to that single value (treating null as empty) so the backend + // does not stream work items the worker will then reject after the fact. + IReadOnlyList? strictWorkerVersions = + workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict + ? [workerOptions.Versioning.Version ?? string.Empty] + : 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/IVersionedTaskFactory.cs b/src/Worker/Core/IVersionedTaskFactory.cs new file mode 100644 index 000000000..51e721aeb --- /dev/null +++ b/src/Worker/Core/IVersionedTaskFactory.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.DurableTask.Worker; + +/// +/// Creates orchestrator and activity instances by logical name and requested version. +/// Implemented by the default factory when the registry contains versioned registrations. +/// +internal interface IVersionedTaskFactory +{ + /// + /// 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); + + /// + /// Tries to create an activity that matches the provided logical name and version. + /// + /// The activity name. + /// The activity version. + /// The service provider. + /// The created activity, if found. + /// true if a matching activity was created; otherwise false. + bool TryCreateActivity( + TaskName name, + TaskVersion version, + IServiceProvider serviceProvider, + [NotNullWhen(true)] out ITaskActivity? activity); +} diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index 646858c47..58c9787fe 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -129,6 +129,21 @@ public override async Task CallActivityAsync( object? input = null, TaskOptions? options = null) { + // Returns the version to schedule the activity with. If the caller passed TaskOptions.Version, + // use that (including TaskVersion.Unversioned for an explicit unversioned request); otherwise inherit + // the orchestration instance version. The unified dispatch rule on the worker side (exact match; + // fallback to unversioned only when the name has no versioned registration) applies regardless of + // how the version was selected. + static string GetRequestedActivityVersion(TaskOptions? taskOptions, string inheritedVersion) + { + if (taskOptions?.Version is TaskVersion explicitVersion) + { + return explicitVersion.Version ?? string.Empty; + } + + return inheritedVersion; + } + // 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. @@ -143,14 +158,12 @@ 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 = GetRequestedActivityVersion(options, this.innerContext.Version); + + // ScheduleTaskOptions.Builder.WithTags requires a non-null dictionary. When the caller did + // not supply tags, use the shared empty ImmutableDictionary instance instead of allocating + // a fresh Dictionary on every activity call. + IDictionary tags = options?.Tags ?? ImmutableDictionary.Empty; // TODO: Cancellation (https://github.com/microsoft/durabletask-dotnet/issues/7) #pragma warning disable 0618 @@ -158,7 +171,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) @@ -170,7 +183,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(), @@ -183,7 +196,7 @@ public override async Task CallActivityAsync( { return await this.innerContext.ScheduleTask( name.Name, - this.innerContext.Version, + requestedVersion, options: ScheduleTaskOptions.CreateBuilder() .WithTags(tags) .Build(), @@ -208,8 +221,13 @@ public override async Task CallSubOrchestratorAsync( static string? GetInstanceId(TaskOptions? options) => options is SubOrchestrationOptions derived ? derived.InstanceId : null; string instanceId = GetInstanceId(options) ?? this.NewGuid().ToString("N"); - string defaultVersion = this.GetDefaultVersion(); - string version = options is SubOrchestrationOptions { Version: { } v } ? v.Version : defaultVersion; + + // Mirror the activity-dispatch rule: a sub-orchestration scheduled without an explicit Version + // inherits the version of the currently executing parent instance. This keeps a v2 parent's call + // tree on v2 by default and removes the previous asymmetry where activities inherited from the + // parent while sub-orchestrations fell through to Versioning.DefaultVersion (which is meant for + // newly started top-level instances, not for children spawned mid-flight). + string version = options?.Version is { } v ? v.Version : this.innerContext.Version; Check.NotEntity(this.invocationContext.Options.EnableEntitySupport, instanceId); // if this orchestration uses entities, first validate that the suborchestration call is allowed in the current context @@ -570,21 +588,4 @@ async Task InvokeWithCustomRetryHandler( } } - // The default version can come from two different places depending on the context of the invocation. - string GetDefaultVersion() - { - // Preferred choice. - if (this.invocationContext.Options.Versioning?.DefaultVersion is { } v) - { - return v; - } - - // Secondary choice. - if (this.Properties.TryGetValue("defaultVersion", out object? propVersion) && propVersion is string v2) - { - return v2; - } - - return string.Empty; - } } diff --git a/src/Worker/Core/Worker.csproj b/src/Worker/Core/Worker.csproj index daed82b15..131a98785 100644 --- a/src/Worker/Core/Worker.csproj +++ b/src/Worker/Core/Worker.csproj @@ -19,6 +19,11 @@ 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 4c5a18b2b..8f7458b2b 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -709,6 +709,9 @@ await this.ExecuteWithRetryAsync( // 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( @@ -718,8 +721,17 @@ await this.ExecuteWithRetryAsync( 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 IVersionedTaskFactory 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. @@ -741,10 +753,13 @@ await this.ExecuteWithRetryAsync( } 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, }; } @@ -881,7 +896,18 @@ 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); + bool found = this.worker.Factory is IVersionedTaskFactory versionedFactory + ? versionedFactory.TryCreateActivity( + name, + requestedVersion, + scope.ServiceProvider, + out ITaskActivity? activity) + : 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. @@ -890,10 +916,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) + ? $"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..96a12b140 100644 --- a/test/Abstractions.Tests/TaskOptionsTests.cs +++ b/test/Abstractions.Tests/TaskOptionsTests.cs @@ -8,16 +8,18 @@ namespace Microsoft.DurableTask.Tests; public class TaskOptionsTests { [Fact] - public void Empty_Ctors_Okay() - { + public void Empty_Ctors_Okay() + { TaskOptions options = new(); options.Retry.Should().BeNull(); options.Tags.Should().BeNull(); + options.Version.Should().BeNull(); SubOrchestrationOptions subOptions = new(); subOptions.Retry.Should().BeNull(); subOptions.Tags.Should().BeNull(); subOptions.InstanceId.Should().BeNull(); + subOptions.Version.Should().BeNull(); StartOrchestrationOptions startOptions = new(); startOptions.Version.Should().BeNull(); @@ -154,11 +156,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,17 +168,52 @@ public void TaskOptions_CopyConstructor_CopiesAllProperties() TaskOptions copy = new(original); // Assert - copy.Retry.Should().Be(original.Retry); - copy.Tags.Should().BeSameAs(original.Tags); + copy.Retry.Should().Be(original.Retry); + copy.Tags.Should().BeSameAs(original.Tags); + } + + [Fact] + public void TaskOptions_VersionInitializer_PersistsValue() + { + // Arrange + TaskVersion version = new("1.0"); + + // Act + TaskOptions options = new() { Version = version }; + + // Assert + options.Version.Should().Be(version); } [Fact] - public void SubOrchestrationOptions_CopyConstructor_CopiesAllProperties() + public void TaskOptions_CopyConstructor_CopiesAllPropertiesIncludingVersion() { // Arrange RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); TaskRetryOptions retry = new(policy); Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; + TaskVersion version = new("1.0"); + TaskOptions original = new(retry, tags) + { + Version = version, + }; + + // Act + TaskOptions 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 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"); SubOrchestrationOptions original = new(retry, instanceId) 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..9edabc928 --- /dev/null +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -0,0 +1,283 @@ +// 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. +/// +/// Stamps version v1 on the activity call. A non-null .Version overrides this baked version. +/// +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) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new TaskOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : new TaskOptions(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. +/// +/// Stamps version v1 on the activity call. A non-null .Version overrides this baked version. +/// +public static Task CallInvoiceActivityV1Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v1"")); +} + +/// +/// Calls the activity. +/// +/// Stamps version v2 on the activity call. A non-null .Version overrides this baked version. +/// +public static Task CallInvoiceActivityV2Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v2"")); +} + +static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new TaskOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : new TaskOptions(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. +/// +/// Stamps version v1 on the activity call. A non-null .Version overrides this baked version. +/// +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) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new TaskOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : new TaskOptions(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..c19d5e86c --- /dev/null +++ b/test/Generators.Tests/VersionedOrchestratorTests.cs @@ -0,0 +1,603 @@ +// 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. +/// +/// Stamps version v1 on the started instance. A non-null .Version overrides this baked version. +/// +public static Task ScheduleNewInvoiceWorkflowInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// Stamps version v1 on the sub-orchestration. A non-null .Version overrides this baked version. +/// +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) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + if (options.Version is not null) + { + return options; + } + + return options with { Version = new TaskVersion(version) }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : 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. +/// +/// Stamps version v1 on the started instance. A non-null .Version overrides this baked version. +/// +public static Task ScheduleNewInvoiceWorkflowV1InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// Stamps version v1 on the sub-orchestration. A non-null .Version overrides this baked version. +/// +public static Task CallInvoiceWorkflowV1Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Schedules a new instance of the orchestrator. +/// +/// Stamps version v2 on the started instance. A non-null .Version overrides this baked version. +/// +public static Task ScheduleNewInvoiceWorkflowV2InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// Stamps version v2 on the sub-orchestration. A non-null .Version overrides this baked version. +/// +public static Task CallInvoiceWorkflowV2Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + if (options.Version is not null) + { + return options; + } + + return options with { Version = new TaskVersion(version) }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : 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. +/// +/// Stamps version v1 on the started instance. A non-null .Version overrides this baked version. +/// +public static Task ScheduleNewInvoiceWorkflowV1InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// Stamps version v1 on the sub-orchestration. A non-null .Version overrides this baked version. +/// +public static Task CallInvoiceWorkflowV1Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Schedules a new instance of the orchestrator. +/// +/// Stamps version v2 on the started instance. A non-null .Version overrides this baked version. +/// +public static Task ScheduleNewInvoiceWorkflowV2InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""invoiceworkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// Stamps version v2 on the sub-orchestration. A non-null .Version overrides this baked version. +/// +public static Task CallInvoiceWorkflowV2Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""invoiceworkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + if (options.Version is not null) + { + return options; + } + + return options with { Version = new TaskVersion(version) }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : 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. +/// +/// Stamps version v1 on the started instance. A non-null .Version overrides this baked version. +/// +public static Task ScheduleNewInvoiceWorkflowInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// Stamps version v1 on the sub-orchestration. A non-null .Version overrides this baked version. +/// +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) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + if (options.Version is not null) + { + return options; + } + + return options with { Version = new TaskVersion(version) }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : 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. +/// +/// Stamps version v1 on the started instance. A non-null .Version overrides this baked version. +/// +public static Task ScheduleNewInvoiceWorkflowInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// Stamps version v1 on the sub-orchestration. A non-null .Version overrides this baked version. +/// +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) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + if (options.Version is not null) + { + return options; + } + + return options with { Version = new TaskVersion(version) }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : 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..01b97aa54 --- /dev/null +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs @@ -0,0 +1,231 @@ +// 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 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 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()); + } + + /// + /// Verifies UseVersioning(MatchStrategy = CurrentOrOlder) composes with multi-version registrations: + /// the per-task registry picks the implementation that exactly matches the inbound instance version, + /// while UseVersioning's strategy still gates which instance versions the worker accepts. This is + /// the central composition property of the simplification — the two features are not mutually + /// exclusive. + /// + [Fact] + public async Task UseVersioning_CurrentOrOlder_WithMultiVersionRegistry_RoutesEachVersionToItsImplementation() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions + { + Version = "v2", + DefaultVersion = "v2", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.CurrentOrOlder, + }); + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddOrchestrator(); + }); + }); + + // v1 instance is accepted (<= worker v2) and dispatched to V1. + string v1Id = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "VersionedClassSyntax", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("v1"), + }); + OrchestrationMetadata v1Metadata = await server.Client.WaitForInstanceCompletionAsync( + v1Id, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(v1Metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, v1Metadata.RuntimeStatus); + Assert.Equal("v1:5", v1Metadata.ReadOutputAs()); + + // v2 instance is accepted and dispatched to V2. + string v2Id = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "VersionedClassSyntax", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("v2"), + }); + OrchestrationMetadata v2Metadata = await server.Client.WaitForInstanceCompletionAsync( + v2Id, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(v2Metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, v2Metadata.RuntimeStatus); + Assert.Equal("v2:5", v2Metadata.ReadOutputAs()); + } +} diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs new file mode 100644 index 000000000..41bda8449 --- /dev/null +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Microsoft.DurableTask.Worker; + +namespace Microsoft.DurableTask.Grpc.Tests; + +/// +/// Class-based versioned orchestrators used by integration tests. +/// +public static class VersionedClassSyntaxTestOrchestration +{ + /// + /// 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 TaskOptions + { + 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 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 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 98c7a3702..6170f5401 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -187,7 +187,7 @@ public void WorkItemFilters_DefaultNullWithVersioningNone_WhenExplicitlyOptedIn( } [Fact] - public void WorkItemFilters_DefaultVersionWithVersioningStrict_WhenExplicitlyOptedIn() + public void WorkItemFilters_DefaultVersionWithVersioningStrict_NarrowsGeneratedFilters_WhenExplicitlyOptedIn() { // Arrange ServiceCollection services = new(); @@ -216,8 +216,200 @@ 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_StrictWithEmptyWorkerVersion_NarrowsFilterToUnversioned() + { + // Arrange — Strict + Version="" means the worker only accepts unversioned work items. The filter + // must narrow each name to [""] rather than emitting no version constraint (which would match all + // versions and leave the worker to reject mismatches after the fact). + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddActivity(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = string.Empty, + 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([string.Empty]); + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Name.Should().Be("FilterActivity"); + actual.Activities[0].Versions.Should().BeEquivalentTo([string.Empty]); + } + + [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] @@ -804,6 +996,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) @@ -812,6 +1033,35 @@ 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 { } diff --git a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs new file mode 100644 index 000000000..e58be36fe --- /dev/null +++ b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs @@ -0,0 +1,155 @@ +// 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 = ((IVersionedTaskFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + 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 = ((IVersionedTaskFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + 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 = ((IVersionedTaskFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + 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 = ((IVersionedTaskFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v1"), + Mock.Of(), + 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_WithMixedRegistrations_DoesNotFallBackToUnversionedWhenAnotherVersionIsRegistered() + { + // Arrange: register an unversioned activity and a v1 activity, then request v2. + // Because the name has at least one versioned registration, the unversioned registration must NOT + // be used as a fallback for the unmatched v2 request. + DurableTaskRegistry registry = new(); + registry.AddActivity(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + 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..3258ba4f7 --- /dev/null +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -0,0 +1,178 @@ +// 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 = ((IVersionedTaskFactory)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 = ((IVersionedTaskFactory)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 = ((IVersionedTaskFactory)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 = ((IVersionedTaskFactory)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_DoesNotFallBackForUnknownVersion() + { + // Arrange — name "InvoiceWorkflow" has both versioned (v1, v2) and unversioned registrations. + // A request for v3 (no exact match) must NOT silently fall back to the unversioned registration: + // doing so would route the call to a different implementation than the caller asked for. + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v3"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeFalse(); + orchestrator.Should().BeNull(); + } + + [Fact] + public void TryCreateOrchestrator_WithOnlyUnversionedRegistration_FallsBackForVersionedRequest() + { + // Arrange — name "InvoiceWorkflow" has only the unversioned registration. A versioned request + // is allowed to fall back to it (migration path: pre-versioning instances scheduled with + // a specific version against a registry that hasn't migrated yet). + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v1"), + 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 17ed6f638..41375d272 100644 --- a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs +++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Generic; using System.Reflection; using DurableTask.Core; using DurableTask.Core.Serializing.Internal; @@ -129,6 +130,260 @@ public void ContinueAsNew_WithPreserveUnprocessedEvents_ForwardsLateArrivingEven innerContext.LastContinueAsNewInput.Should().Be("new-input"); } + [Fact] + public async Task CallActivityAsync_TaskOptionsVersionOverridesInheritedOrchestrationVersion() + { + // 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 + { + Version = "v1", + }); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v1"); + innerContext.LastScheduledTaskInput.Should().Be(123); + } + + [Fact] + public async Task CallActivityAsync_TaskOptionsVersionOverridesInheritedOrchestrationVersion_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 TaskOptions(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_TaskOptionsVersionOverridesInheritedOrchestrationVersion_WithRetryHandler() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + TaskOptions 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); + } + + [Fact] + public async Task CallActivityAsync_PreservesCallerSuppliedTags() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act — caller supplies arbitrary tags; the SDK preserves them verbatim. + await wrapper.CallActivityAsync( + "TestActivity", + 123, + new TaskOptions(tags: new Dictionary + { + ["caller.tag"] = "caller-value", + })); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v2"); + innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().Contain("caller.tag", "caller-value"); + } + + [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); + } + + [Fact] + public async Task CallActivityAsync_NullTaskOptionsVersion_InheritsOrchestrationVersion() + { + // Arrange — TaskOptions present but Version not set => inherit (same as plain TaskOptions). + 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"); + } + + [Fact] + public async Task CallActivityAsync_ExplicitUnversionedActivityOption_BypassesInherit() + { + // Arrange — from a v2 orchestration the caller explicitly requests the unversioned activity. + 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 { Version = TaskVersion.Unversioned }); + + // Assert — empty version is sent (the unversioned activity), instead of inheriting v2. + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be(string.Empty); + } + + [Fact] + public async Task CallSubOrchestratorAsync_PlainOptions_InheritsParentInstanceVersion() + { + // Arrange — sub-orchestration scheduled by a v2 parent without explicit options should inherit v2, + // matching the activity-dispatch rule. Worker-level Versioning.DefaultVersion does not apply to + // sub-orchestrations spawned from inside an executing parent. + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new( + "Test", + new DurableTaskWorkerOptions + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions { DefaultVersion = "9.9" }, + }, + NullLoggerFactory.Instance, + null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallSubOrchestratorAsync("ChildOrchestration", 123); + + // Assert + innerContext.LastSubOrchestrationName.Should().Be("ChildOrchestration"); + innerContext.LastSubOrchestrationVersion.Should().Be("v2"); + } + + [Fact] + public async Task CallSubOrchestratorAsync_UnversionedParent_StampsEmptyVersion() + { + // Arrange — when the parent orchestration is unversioned, the sub-orchestration is also + // unversioned. Worker-level Versioning.DefaultVersion is intentionally ignored here. + TrackingOrchestrationContext innerContext = new(); + OrchestrationInvocationContext invocationContext = new( + "Test", + new DurableTaskWorkerOptions + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions { DefaultVersion = "9.9" }, + }, + NullLoggerFactory.Instance, + null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallSubOrchestratorAsync("ChildOrchestration", 123); + + // Assert + innerContext.LastSubOrchestrationName.Should().Be("ChildOrchestration"); + innerContext.LastSubOrchestrationVersion.Should().Be(string.Empty); + } + + [Fact] + public async Task CallSubOrchestratorAsync_ExplicitVersion_OverridesInheritedParentVersion() + { + // Arrange — explicit SubOrchestrationOptions.Version wins over the inherited parent version. + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallSubOrchestratorAsync( + "ChildOrchestration", + 123, + new SubOrchestrationOptions { Version = "v1" }); + + // Assert + innerContext.LastSubOrchestrationName.Should().Be("ChildOrchestration"); + innerContext.LastSubOrchestrationVersion.Should().Be("v1"); + } + + [Fact] + public async Task CallSubOrchestratorAsync_ExplicitUnversionedOption_BypassesInheritedParentVersion() + { + // Arrange — explicit TaskVersion.Unversioned on a v2 parent must produce an unversioned + // sub-orchestration call, matching the activity-side explicit-unversioned semantics. + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallSubOrchestratorAsync( + "ChildOrchestration", + 123, + new SubOrchestrationOptions { Version = TaskVersion.Unversioned }); + + // Assert + innerContext.LastSubOrchestrationName.Should().Be("ChildOrchestration"); + innerContext.LastSubOrchestrationVersion.Should().Be(string.Empty); + } + + static IReadOnlyDictionary GetLastScheduledTaskTags(TrackingOrchestrationContext innerContext) + { + PropertyInfo tagsProperty = innerContext.LastScheduledTaskOptions!.GetType().GetProperty("Tags")!; + return (IReadOnlyDictionary)tagsProperty.GetValue(innerContext.LastScheduledTaskOptions)!; + } + static void InvokeCompleteExternalEvent(TaskOrchestrationContextWrapper wrapper, string eventName, string rawEventPayload) { CompleteExternalEventMethod.Invoke(wrapper, [eventName, rawEventPayload]); @@ -136,19 +391,32 @@ static void InvokeCompleteExternalEvent(TaskOrchestrationContextWrapper wrapper, 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 string? LastSubOrchestrationName { get; private set; } + + public string? LastSubOrchestrationVersion { get; private set; } + public List<(string InstanceId, string EventName, object EventData)> SentEvents { get; } = []; public override void ContinueAsNew(object input) @@ -164,13 +432,25 @@ public override void ContinueAsNew(string newVersion, object input) } public override Task CreateSubOrchestrationInstance(string name, string version, object input) - => throw new NotImplementedException(); + { + this.LastSubOrchestrationName = name; + this.LastSubOrchestrationVersion = version; + return Task.FromResult(default(T)!); + } public override Task CreateSubOrchestrationInstance(string name, string version, string instanceId, object input) - => throw new NotImplementedException(); + { + this.LastSubOrchestrationName = name; + this.LastSubOrchestrationVersion = version; + return Task.FromResult(default(T)!); + } public override Task CreateSubOrchestrationInstance(string name, string version, string instanceId, object input, IDictionary tags) - => throw new NotImplementedException(); + { + this.LastSubOrchestrationName = name; + this.LastSubOrchestrationVersion = version; + return Task.FromResult(default(T)!); + } public override Task CreateTimer(DateTime fireAt, T state) => throw new NotImplementedException(); @@ -179,7 +459,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) { diff --git a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index db6c98da5..fde8e49b6 100644 --- a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs +++ b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs @@ -489,6 +489,68 @@ public async Task ConnectAsync_VeryLargeHelloDeadline_UsesUtcMaxValueDeadline() deadline.Should().Be(DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc)); } + [Fact] + public void Constructor_MultiVersionRegistryWithStrictWorkerVersioning_DoesNotThrow() + { + // Arrange — combine UseVersioning(Strict) with multi-version registrations. Both are now part of + // the same versioning feature: UseVersioning's match strategy decides which instance versions to + // accept off the wire, and the per-task registry decides which implementation handles a surviving + // work item. They no longer fail-fast at construction. Registry contents are not inspected here + // (see VersionedClassSyntaxIntegrationTests for the runtime composition test). + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + }, + Logging = { UseLegacyCategories = false }, + }; + + // Act + Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void Constructor_MultiVersionRegistryWithoutWorkerVersioning_DoesNotThrow() + { + // Arrange + DurableTaskWorkerOptions workerOptions = new() + { + Logging = { UseLegacyCategories = false }, + }; + + // Act + Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void Constructor_WorkerVersioningWithoutMultiVersionRegistry_DoesNotThrow() + { + // Arrange + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + }, + Logging = { UseLegacyCategories = false }, + }; + + // Act + Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance); + + // Assert + act.Should().NotThrow(); + } + static GrpcDurableTaskWorker CreateWorker(GrpcDurableTaskWorkerOptions grpcOptions) { return CreateWorker(grpcOptions, new DurableTaskWorkerOptions(), NullLoggerFactory.Instance); @@ -500,16 +562,25 @@ static GrpcDurableTaskWorker CreateWorker( ILoggerFactory loggerFactory) { Mock factoryMock = new(MockBehavior.Strict); + return CreateWorker(grpcOptions, workerOptions, loggerFactory, factoryMock.Object); + } + static GrpcDurableTaskWorker CreateWorker( + GrpcDurableTaskWorkerOptions grpcOptions, + DurableTaskWorkerOptions workerOptions, + ILoggerFactory loggerFactory, + IDurableTaskFactory factory) + { return new GrpcDurableTaskWorker( name: "Test", - factory: factoryMock.Object, + factory: factory, grpcOptions: new OptionsMonitorStub(grpcOptions), workerOptions: new OptionsMonitorStub(workerOptions), services: Mock.Of(), loggerFactory: loggerFactory, orchestrationFilter: null, - exceptionPropertiesProvider: null); + exceptionPropertiesProvider: null, + workItemFiltersMonitor: null); } static Task InvokeExecuteAsync(GrpcDurableTaskWorker worker, CancellationToken cancellationToken)