From 0fc9c08afccaf644af511024a0254e7ea84db4cc Mon Sep 17 00:00:00 2001 From: torosent Date: Tue, 31 Mar 2026 15:28:33 -0700 Subject: [PATCH 01/52] Ignore repo worktrees Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4a1224346..5a0ffe3de 100644 --- a/.gitignore +++ b/.gitignore @@ -351,3 +351,4 @@ MigrationBackup/ # Rider (cross platform .NET/C# tools) working folder .idea/ +.worktrees/ From 0307de4b08ebdd66dde5ecbf525be908a98c2093 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 02/52] feat: add orchestrator version attribute Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DurableTaskVersionAttribute.cs | 29 ++++++++ src/Abstractions/TypeExtensions.cs | 18 ++++- .../DurableTaskVersionAttributeTests.cs | 70 +++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/Abstractions/DurableTaskVersionAttribute.cs create mode 100644 test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs diff --git a/src/Abstractions/DurableTaskVersionAttribute.cs b/src/Abstractions/DurableTaskVersionAttribute.cs new file mode 100644 index 000000000..1919c376b --- /dev/null +++ b/src/Abstractions/DurableTaskVersionAttribute.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Indicates the version of a class-based durable orchestrator. +/// +/// +/// This attribute is only consumed for orchestrator registrations and source generation. +/// Activities and entities ignore this attribute in v1. +/// +[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. + public DurableTaskVersionAttribute(string? version = null) + { + this.Version = string.IsNullOrEmpty(version) ? default : new TaskVersion(version!); + } + + /// + /// Gets the orchestrator version declared on the attributed class. + /// + public TaskVersion Version { get; } +} 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/test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs b/test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs new file mode 100644 index 000000000..694adab5c --- /dev/null +++ b/test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Tests; + +using System.Reflection; + +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 = GetDurableTaskVersion(type); + + // Assert + version.Version.Should().Be("v1"); + } + + [Fact] + public void GetDurableTaskVersion_WithoutAttribute_ReturnsDefault() + { + // Arrange + Type type = typeof(UnversionedTestOrchestrator); + + // Act + TaskVersion version = GetDurableTaskVersion(type); + + // 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); + } + + static TaskVersion GetDurableTaskVersion(Type type) + { + MethodInfo method = typeof(TaskName).Assembly + .GetType("Microsoft.DurableTask.TypeExtensions", throwOnError: true)! + .GetMethod("GetDurableTaskVersion", BindingFlags.Static | BindingFlags.NonPublic)!; + + return (TaskVersion)method.Invoke(null, new object[] { type })!; + } +} From b64fa00b7a8c6518fd1c9037a9f42bf107a3bc9c Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 03/52] fix: align version attribute tests with spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/Abstractions.csproj | 1 + .../DurableTaskVersionAttributeTests.cs | 15 ++------------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/Abstractions/Abstractions.csproj b/src/Abstractions/Abstractions.csproj index db8be76ab..5fb379322 100644 --- a/src/Abstractions/Abstractions.csproj +++ b/src/Abstractions/Abstractions.csproj @@ -22,6 +22,7 @@ + diff --git a/test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs b/test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs index 694adab5c..cca3f8ba3 100644 --- a/test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs +++ b/test/Abstractions.Tests/DurableTaskVersionAttributeTests.cs @@ -3,8 +3,6 @@ namespace Microsoft.DurableTask.Tests; -using System.Reflection; - public class DurableTaskVersionAttributeTests { [Fact] @@ -27,7 +25,7 @@ public void GetDurableTaskVersion_WithAttribute_ReturnsVersion() Type type = typeof(VersionedTestOrchestrator); // Act - TaskVersion version = GetDurableTaskVersion(type); + TaskVersion version = type.GetDurableTaskVersion(); // Assert version.Version.Should().Be("v1"); @@ -40,7 +38,7 @@ public void GetDurableTaskVersion_WithoutAttribute_ReturnsDefault() Type type = typeof(UnversionedTestOrchestrator); // Act - TaskVersion version = GetDurableTaskVersion(type); + TaskVersion version = type.GetDurableTaskVersion(); // Assert version.Should().Be(default(TaskVersion)); @@ -58,13 +56,4 @@ sealed class UnversionedTestOrchestrator : TaskOrchestrator public override Task RunAsync(TaskOrchestrationContext context, string input) => Task.FromResult(input); } - - static TaskVersion GetDurableTaskVersion(Type type) - { - MethodInfo method = typeof(TaskName).Assembly - .GetType("Microsoft.DurableTask.TypeExtensions", throwOnError: true)! - .GetMethod("GetDurableTaskVersion", BindingFlags.Static | BindingFlags.NonPublic)!; - - return (TaskVersion)method.Invoke(null, new object[] { type })!; - } } From 1ca9f898263107d85f78a58cab93babd807748a8 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 04/52] feat: store orchestrator registrations by version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DurableTaskRegistry.Orchestrators.cs | 66 ++++++++++- src/Abstractions/DurableTaskRegistry.cs | 32 +----- src/Abstractions/OrchestratorVersionKey.cs | 65 +++++++++++ src/Worker/Core/DurableTaskFactory.cs | 6 +- .../Core/DurableTaskWorkerWorkItemFilters.cs | 2 +- .../DurableTaskRegistryVersioningTests.cs | 103 ++++++++++++++++++ 6 files changed, 236 insertions(+), 38 deletions(-) create mode 100644 src/Abstractions/OrchestratorVersionKey.cs create mode 100644 test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs diff --git a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs index 7ad7583f0..53c2ecf3c 100644 --- a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs +++ b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs @@ -30,6 +30,55 @@ 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. + /// + /// + public DurableTaskRegistry AddOrchestrator(TaskName name, TaskVersion version, Func factory) + { + Check.NotDefault(name); + Check.NotNull(factory); + + OrchestratorVersionKey key = new(name, version); + if (this.Orchestrators.ContainsKey(key)) + { + throw new ArgumentException( + $"An {nameof(ITaskOrchestrator)} named '{name}' with version '{version.Version ?? string.Empty}' is already added.", + nameof(name)); + } + + this.Orchestrators.Add(key, _ => factory()); + return this; + } + + /// + /// Registers an orchestrator factory. + /// + /// The name of the orchestrator. + /// The orchestrator factory. + /// This registry instance, for call chaining. + /// + /// Thrown if any of the following are true: + /// + /// If is default. + /// If is already registered. + /// If is null. + /// + /// + public DurableTaskRegistry AddOrchestrator(TaskName name, Func factory) + => this.AddOrchestrator(name, default, factory); + /// /// Registers an orchestrator factory. /// @@ -40,7 +89,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 +101,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 +134,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 +145,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..5ec14583f 100644 --- a/src/Abstractions/DurableTaskRegistry.cs +++ b/src/Abstractions/DurableTaskRegistry.cs @@ -22,8 +22,8 @@ public sealed partial class DurableTaskRegistry /// /// Gets the currently registered orchestrators. /// - internal IDictionary> Orchestrators { get; } - = new Dictionary>(); + internal IDictionary> Orchestrators { get; } + = new Dictionary>(); /// /// Gets the currently registered entities. @@ -58,34 +58,6 @@ public DurableTaskRegistry AddActivity(TaskName name, Func - /// 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) - { - Check.NotDefault(name); - Check.NotNull(factory); - if (this.Orchestrators.ContainsKey(name)) - { - throw new ArgumentException( - $"An {nameof(ITaskOrchestrator)} named '{name}' is already added.", nameof(name)); - } - - this.Orchestrators.Add(name, _ => factory()); - return this; - } - /// /// Registers an entity factory. /// diff --git a/src/Abstractions/OrchestratorVersionKey.cs b/src/Abstractions/OrchestratorVersionKey.cs new file mode 100644 index 000000000..30c2a62ba --- /dev/null +++ b/src/Abstractions/OrchestratorVersionKey.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Represents the logical name and version of a registered orchestrator. +/// +internal readonly struct OrchestratorVersionKey : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The orchestrator name. + /// The orchestrator version. + public OrchestratorVersionKey(TaskName name, TaskVersion version) + : this(name.Name, version.Version) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The orchestrator name. + /// The orchestrator version. + public OrchestratorVersionKey(string name, string? version) + { + this.Name = Check.NotNullOrEmpty(name, nameof(name)); + this.Version = version ?? string.Empty; + } + + /// + /// Gets the logical orchestrator name. + /// + public string Name { get; } + + /// + /// Gets the orchestrator version. + /// + public string Version { get; } + + /// + /// Determines whether the specified key is equal to the current key. + /// + /// The key to compare with the current key. + /// true if the keys are equal; otherwise false. + public bool Equals(OrchestratorVersionKey other) + { + return string.Equals(this.Name, other.Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(this.Version, other.Version, StringComparison.OrdinalIgnoreCase); + } + + /// + public override bool Equals(object? obj) => obj is OrchestratorVersionKey other && this.Equals(other); + + /// + public override int GetHashCode() + { + unchecked + { + return (StringComparer.OrdinalIgnoreCase.GetHashCode(this.Name) * 397) + ^ StringComparer.OrdinalIgnoreCase.GetHashCode(this.Version); + } + } +} diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 0e77a584a..f4324b317 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -12,7 +12,7 @@ namespace Microsoft.DurableTask.Worker; sealed class DurableTaskFactory : IDurableTaskFactory2 { readonly IDictionary> activities; - readonly IDictionary> orchestrators; + readonly IDictionary> orchestrators; readonly IDictionary> entities; /// @@ -23,7 +23,7 @@ sealed class DurableTaskFactory : IDurableTaskFactory2 /// The entity factories. internal DurableTaskFactory( IDictionary> activities, - IDictionary> orchestrators, + IDictionary> orchestrators, IDictionary> entities) { this.activities = Check.NotNull(activities); @@ -50,7 +50,7 @@ public bool TryCreateActivity( public bool TryCreateOrchestrator( TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskOrchestrator? orchestrator) { - if (this.orchestrators.TryGetValue(name, out Func? factory)) + if (this.orchestrators.TryGetValue(new OrchestratorVersionKey(name, default), out Func? factory)) { orchestrator = factory.Invoke(serviceProvider); return true; diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index 8a5df2f1d..f128c089c 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -47,7 +47,7 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable { Orchestrations = registry.Orchestrators.Select(orchestration => new OrchestrationFilter { - Name = orchestration.Key, + Name = orchestration.Key.Name, Versions = versions, }).ToList(), Activities = registry.Activities.Select(activity => new ActivityFilter diff --git a/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs b/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs new file mode 100644 index 000000000..290457084 --- /dev/null +++ b/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs @@ -0,0 +1,103 @@ +// 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(); + } + + [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"); + } + + 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); + } +} From fc09993b7308c68edfa31fdc1f9dd868287ade78 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 05/52] fix: preserve unversioned worker registry behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskFactory.cs | 23 +++++++++ .../Core/DurableTaskWorkerWorkItemFilters.cs | 13 +++-- .../UseWorkItemFiltersTests.cs | 47 ++++++++++++++++++- .../DurableTaskRegistryTests.Orchestrators.cs | 29 ++++++++++++ 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index f4324b317..6377642f6 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -56,6 +56,29 @@ public bool TryCreateOrchestrator( return true; } + Func? matchingFactory = null; + foreach (KeyValuePair> registration in this.orchestrators) + { + if (!string.Equals(registration.Key.Name, name.Name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (matchingFactory is not null) + { + orchestrator = null; + return false; + } + + matchingFactory = registration.Value; + } + + if (matchingFactory is not null) + { + orchestrator = matchingFactory.Invoke(serviceProvider); + return true; + } + orchestrator = null; return false; } diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index f128c089c..a22b7cd10 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -45,11 +45,14 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable return new DurableTaskWorkerWorkItemFilters { - Orchestrations = registry.Orchestrators.Select(orchestration => new OrchestrationFilter - { - Name = orchestration.Key.Name, - Versions = versions, - }).ToList(), + Orchestrations = registry.Orchestrators + .GroupBy(orchestration => orchestration.Key.Name, StringComparer.OrdinalIgnoreCase) + .Select(group => new OrchestrationFilter + { + Name = group.Key, + Versions = versions, + }) + .ToList(), Activities = registry.Activities.Select(activity => new ActivityFilter { Name = activity.Key, diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index adaac30a7..42564178b 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -88,6 +88,31 @@ public void WorkItemFilters_DefaultFromRegistry_WhenExplicitlyOptedIn() actual.Activities.Should().ContainSingle(a => a.Name == nameof(TestActivity)); } + [Fact] + public void WorkItemFilters_VersionedOrchestrators_DeduplicateLogicalNames() + { + // 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(o => o.Name == "VersionedWorkflow"); + } + [Fact] public void WorkItemFilters_DefaultWithEntity_WhenExplicitlyOptedIn() { @@ -410,7 +435,27 @@ public override Task RunAsync(TaskActivityContext context, object input) } } + [DurableTask("VersionedWorkflow")] + [DurableTaskVersion("v1")] + sealed class VersionedWorkflowV1 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, object input) + { + throw new NotImplementedException(); + } + } + + [DurableTask("VersionedWorkflow")] + [DurableTaskVersion("v2")] + sealed class VersionedWorkflowV2 : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, object input) + { + throw new NotImplementedException(); + } + } + sealed class TestEntity : TaskEntity { } -} \ No newline at end of file +} diff --git a/test/Worker/Core.Tests/DurableTaskRegistryTests.Orchestrators.cs b/test/Worker/Core.Tests/DurableTaskRegistryTests.Orchestrators.cs index ebddb3175..73cdba671 100644 --- a/test/Worker/Core.Tests/DurableTaskRegistryTests.Orchestrators.cs +++ b/test/Worker/Core.Tests/DurableTaskRegistryTests.Orchestrators.cs @@ -108,6 +108,25 @@ public void AddOrchestrator_Generic2_Success() actual.Should().BeOfType(); } + [Fact] + public void BuildFactory_SingleVersionedOrchestrator_CanCreateByLogicalName() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = factory.TryCreateOrchestrator( + "VersionedWorkflow", + Mock.Of(), + out ITaskOrchestrator? actual); + + // Assert + found.Should().BeTrue(); + actual.Should().BeOfType(); + } + [Fact] public void AddOrchestrator_Generic1_Invalid() { @@ -192,4 +211,14 @@ public override Task RunAsync(TaskOrchestrationContext context, object i throw new NotImplementedException(); } } + + [DurableTask("VersionedWorkflow")] + [DurableTaskVersion("v1")] + sealed class VersionedTestOrchestrator : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, object input) + { + throw new NotImplementedException(); + } + } } From f9c2743f28b3c40fa7852f03e2b493bc64aebd0a Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 06/52] fix: keep task 2 worker changes compile-safe Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskFactory.cs | 23 ---------- .../Core/DurableTaskWorkerWorkItemFilters.cs | 6 +-- .../UseWorkItemFiltersTests.cs | 46 ------------------- .../DurableTaskRegistryTests.Orchestrators.cs | 29 ------------ 4 files changed, 3 insertions(+), 101 deletions(-) diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 6377642f6..f4324b317 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -56,29 +56,6 @@ public bool TryCreateOrchestrator( return true; } - Func? matchingFactory = null; - foreach (KeyValuePair> registration in this.orchestrators) - { - if (!string.Equals(registration.Key.Name, name.Name, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (matchingFactory is not null) - { - orchestrator = null; - return false; - } - - matchingFactory = registration.Value; - } - - if (matchingFactory is not null) - { - orchestrator = matchingFactory.Invoke(serviceProvider); - return true; - } - orchestrator = null; return false; } diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index a22b7cd10..beffc88ac 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -46,10 +46,10 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable return new DurableTaskWorkerWorkItemFilters { Orchestrations = registry.Orchestrators - .GroupBy(orchestration => orchestration.Key.Name, StringComparer.OrdinalIgnoreCase) - .Select(group => new OrchestrationFilter + .Where(orchestration => orchestration.Key.Version.Length == 0) + .Select(orchestration => new OrchestrationFilter { - Name = group.Key, + Name = orchestration.Key.Name, Versions = versions, }) .ToList(), diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index 42564178b..b7fb895ff 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -88,31 +88,6 @@ public void WorkItemFilters_DefaultFromRegistry_WhenExplicitlyOptedIn() actual.Activities.Should().ContainSingle(a => a.Name == nameof(TestActivity)); } - [Fact] - public void WorkItemFilters_VersionedOrchestrators_DeduplicateLogicalNames() - { - // 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(o => o.Name == "VersionedWorkflow"); - } - [Fact] public void WorkItemFilters_DefaultWithEntity_WhenExplicitlyOptedIn() { @@ -434,27 +409,6 @@ public override Task RunAsync(TaskActivityContext context, object input) throw new NotImplementedException(); } } - - [DurableTask("VersionedWorkflow")] - [DurableTaskVersion("v1")] - sealed class VersionedWorkflowV1 : TaskOrchestrator - { - public override Task RunAsync(TaskOrchestrationContext context, object input) - { - throw new NotImplementedException(); - } - } - - [DurableTask("VersionedWorkflow")] - [DurableTaskVersion("v2")] - sealed class VersionedWorkflowV2 : TaskOrchestrator - { - public override Task RunAsync(TaskOrchestrationContext context, object input) - { - throw new NotImplementedException(); - } - } - sealed class TestEntity : TaskEntity { } diff --git a/test/Worker/Core.Tests/DurableTaskRegistryTests.Orchestrators.cs b/test/Worker/Core.Tests/DurableTaskRegistryTests.Orchestrators.cs index 73cdba671..ebddb3175 100644 --- a/test/Worker/Core.Tests/DurableTaskRegistryTests.Orchestrators.cs +++ b/test/Worker/Core.Tests/DurableTaskRegistryTests.Orchestrators.cs @@ -108,25 +108,6 @@ public void AddOrchestrator_Generic2_Success() actual.Should().BeOfType(); } - [Fact] - public void BuildFactory_SingleVersionedOrchestrator_CanCreateByLogicalName() - { - // Arrange - DurableTaskRegistry registry = new(); - registry.AddOrchestrator(); - IDurableTaskFactory factory = registry.BuildFactory(); - - // Act - bool found = factory.TryCreateOrchestrator( - "VersionedWorkflow", - Mock.Of(), - out ITaskOrchestrator? actual); - - // Assert - found.Should().BeTrue(); - actual.Should().BeOfType(); - } - [Fact] public void AddOrchestrator_Generic1_Invalid() { @@ -211,14 +192,4 @@ public override Task RunAsync(TaskOrchestrationContext context, object i throw new NotImplementedException(); } } - - [DurableTask("VersionedWorkflow")] - [DurableTaskVersion("v1")] - sealed class VersionedTestOrchestrator : TaskOrchestrator - { - public override Task RunAsync(TaskOrchestrationContext context, object input) - { - throw new NotImplementedException(); - } - } } From 2e12c4eca7f3038f4cfdf93f98f0b90561825004 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 07/52] docs: clarify staged task 2 versioning behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/DurableTaskRegistry.Orchestrators.cs | 4 ++++ src/Worker/Core/DurableTaskFactory.cs | 2 ++ src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs index 53c2ecf3c..67fab540c 100644 --- a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs +++ b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs @@ -45,6 +45,10 @@ TaskName and ITaskOrchestrator singleton /// If is null. /// /// + /// + /// Registration is version-aware in the registry. Version-based worker and factory resolution is introduced in + /// later staged follow-up work. + /// public DurableTaskRegistry AddOrchestrator(TaskName name, TaskVersion version, Func factory) { Check.NotDefault(name); diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index f4324b317..0b16253ee 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -50,6 +50,8 @@ public bool TryCreateActivity( public bool TryCreateOrchestrator( TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskOrchestrator? orchestrator) { + // This staged implementation intentionally resolves only the default-version registration. + // Version-aware worker dispatch is introduced in the subsequent worker-dispatch task. if (this.orchestrators.TryGetValue(new OrchestratorVersionKey(name, default), out Func? factory)) { orchestrator = factory.Invoke(serviceProvider); diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index beffc88ac..4a447db0d 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -35,7 +35,8 @@ 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. + // TODO: Add grouped, version-aware orchestration filters in the later filter task. + // At this stage, versioned orchestrators are intentionally excluded from auto-generated orchestration filters. // 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) From 57698dc1e1d1a9ac5bd48aae713b8b166de21a62 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 08/52] feat: route orchestrators by scheduled version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DurableTaskRegistry.Orchestrators.cs | 5 +- src/Worker/Core/DurableTaskFactory.cs | 18 ++-- .../Core/IVersionedOrchestratorFactory.cs | 26 ++++++ .../Grpc/GrpcDurableTaskWorker.Processor.cs | 21 ++++- .../DurableTaskFactoryVersioningTests.cs | 90 +++++++++++++++++++ 5 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 src/Worker/Core/IVersionedOrchestratorFactory.cs create mode 100644 test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs diff --git a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs index 67fab540c..80543040a 100644 --- a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs +++ b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs @@ -46,8 +46,9 @@ TaskName and ITaskOrchestrator singleton /// /// /// - /// Registration is version-aware in the registry. Version-based worker and factory resolution is introduced in - /// later staged follow-up work. + /// 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) { diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 0b16253ee..8b3ff5cbd 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -9,7 +9,7 @@ namespace Microsoft.DurableTask.Worker; /// /// A factory for creating orchestrators and activities. /// -sealed class DurableTaskFactory : IDurableTaskFactory2 +sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedOrchestratorFactory { readonly IDictionary> activities; readonly IDictionary> orchestrators; @@ -48,11 +48,14 @@ public bool TryCreateActivity( /// public bool TryCreateOrchestrator( - TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskOrchestrator? orchestrator) + TaskName name, + TaskVersion version, + IServiceProvider serviceProvider, + [NotNullWhen(true)] out ITaskOrchestrator? orchestrator) { - // This staged implementation intentionally resolves only the default-version registration. - // Version-aware worker dispatch is introduced in the subsequent worker-dispatch task. - if (this.orchestrators.TryGetValue(new OrchestratorVersionKey(name, default), out Func? factory)) + Check.NotNull(serviceProvider); + OrchestratorVersionKey key = new(name, version); + if (this.orchestrators.TryGetValue(key, out Func? factory)) { orchestrator = factory.Invoke(serviceProvider); return true; @@ -62,6 +65,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/IVersionedOrchestratorFactory.cs b/src/Worker/Core/IVersionedOrchestratorFactory.cs new file mode 100644 index 000000000..625f03e56 --- /dev/null +++ b/src/Worker/Core/IVersionedOrchestratorFactory.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.DurableTask.Worker; + +/// +/// Creates orchestrator instances by exact logical name and version. +/// +internal interface IVersionedOrchestratorFactory +{ + /// + /// Tries to create an orchestrator that matches the provided logical name and version. + /// + /// The orchestrator name. + /// The orchestrator version. + /// The service provider. + /// The created orchestrator, if found. + /// true if a matching orchestrator was created; otherwise false. + bool TryCreateOrchestrator( + TaskName name, + TaskVersion version, + IServiceProvider serviceProvider, + [NotNullWhen(true)] out ITaskOrchestrator? orchestrator); +} diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index a3aa3dab0..0916ddba6 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -618,6 +618,9 @@ await this.client.AbandonTaskOrchestratorWorkItemAsync( // Only continue with the work if the versioning check passed. if (failureDetails == null) { + TaskVersion requestedVersion = string.IsNullOrWhiteSpace(runtimeState.Version) + ? default + : new TaskVersion(runtimeState.Version); name = new TaskName(runtimeState.Name); this.Logger.ReceivedOrchestratorRequest( @@ -627,8 +630,17 @@ await this.client.AbandonTaskOrchestratorWorkItemAsync( runtimeState.NewEvents.Count); await using AsyncServiceScope scope = this.worker.services.CreateAsyncScope(); - if (this.worker.Factory.TryCreateOrchestrator( - name, scope.ServiceProvider, out ITaskOrchestrator? orchestrator)) + bool found = this.worker.Factory is IVersionedOrchestratorFactory versionedFactory + ? versionedFactory.TryCreateOrchestrator( + name, + requestedVersion, + scope.ServiceProvider, + out ITaskOrchestrator? orchestrator) + : this.worker.Factory.TryCreateOrchestrator( + name, + scope.ServiceProvider, + out orchestrator); + if (found) { // Both the factory invocation and the ExecuteAsync could involve user code and need to be handled // as part of try/catch. @@ -650,10 +662,13 @@ await this.client.AbandonTaskOrchestratorWorkItemAsync( } else { + string versionText = requestedVersion.Version ?? string.Empty; failureDetails = new P.TaskFailureDetails { ErrorType = "OrchestratorTaskNotFound", - ErrorMessage = $"No orchestrator task named '{name}' was found.", + ErrorMessage = string.IsNullOrEmpty(versionText) + ? $"No orchestrator task named '{name}' was found." + : $"No orchestrator task named '{name}' with version '{versionText}' was found.", IsNonRetriable = true, }; } diff --git a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs new file mode 100644 index 000000000..5664c1764 --- /dev/null +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.Tests; + +public class DurableTaskFactoryVersioningTests +{ + [Fact] + public void TryCreateOrchestrator_WithMatchingVersion_ReturnsMatchingImplementation() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v2"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + + [Fact] + public void TryCreateOrchestrator_WithoutMatchingVersion_ReturnsFalse() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v2"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeFalse(); + orchestrator.Should().BeNull(); + } + + [Fact] + public void 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"); + } +} From 3911f61e0d45d39594791f98b895071eaf5d7801 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 09/52] feat: include orchestrator versions in work item filters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Core/DurableTaskWorkerWorkItemFilters.cs | 41 ++++++--- .../UseWorkItemFiltersTests.cs | 87 ++++++++++++++++++- 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index 4a447db0d..39b6264ba 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -35,29 +35,42 @@ public class DurableTaskWorkerWorkItemFilters /// A new instance of constructed from the provided registry. internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(DurableTaskRegistry registry, DurableTaskWorkerOptions? workerOptions) { - // TODO: Add grouped, version-aware orchestration filters in the later filter task. - // At this stage, versioned orchestrators are intentionally excluded from auto-generated orchestration filters. - // For now, fetch the version based on the versioning match strategy if defined. If undefined, default to null (all versions match). - IReadOnlyList versions = []; + IReadOnlyList activityVersions = []; if (workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict) { - versions = [workerOptions.Versioning.Version]; + activityVersions = [workerOptions.Versioning.Version]; } - return new DurableTaskWorkerWorkItemFilters - { - Orchestrations = registry.Orchestrators - .Where(orchestration => orchestration.Key.Version.Length == 0) - .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 => + { + bool hasUnversionedRegistration = group.Any(entry => string.IsNullOrWhiteSpace(entry.Key.Version)); + IReadOnlyList versions = hasUnversionedRegistration + ? [] + : group.Select(entry => entry.Key.Version) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new OrchestrationFilter { - Name = orchestration.Key.Name, + Name = group.Key, Versions = versions, - }) - .ToList(), + }; + }) + .ToList(); + + return new DurableTaskWorkerWorkItemFilters + { + Orchestrations = orchestrationFilters, Activities = registry.Activities.Select(activity => new ActivityFilter { Name = activity.Key, - Versions = versions, + Versions = activityVersions, }).ToList(), Entities = registry.Entities.Select(entity => new EntityFilter { diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index b7fb895ff..1da7a4e96 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -181,7 +181,7 @@ public void WorkItemFilters_DefaultNullWithVersioningNone_WhenExplicitlyOptedIn( } [Fact] - public void WorkItemFilters_DefaultVersionWithVersioningStrict_WhenExplicitlyOptedIn() + public void WorkItemFilters_DefaultVersionWithVersioningStrict_AppliesToActivitiesOnly_WhenExplicitlyOptedIn() { // Arrange ServiceCollection services = new(); @@ -210,10 +210,64 @@ 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.Orchestrations.Should().ContainSingle(o => o.Name == nameof(TestOrchestrator) && o.Versions.Count == 0); actual.Activities.Should().ContainSingle(a => a.Name == nameof(TestActivity) && a.Versions.Contains("1.0")); } + [Fact] + public void WorkItemFilters_VersionedOrchestrators_GroupVersionsByLogicalName() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["v1", "v2"]); + } + + [Fact] + public void WorkItemFilters_UnversionedAndVersionedOrchestrators_FallBackToNameOnlyFilter() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); + actual.Orchestrations[0].Versions.Should().BeEmpty(); + } + [Fact] public void WorkItemFilters_DefaultEmptyRegistry_ProducesEmptyFilters() { @@ -402,6 +456,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) From 12cf3abe454a9aa21c62a6e184bf110e6069fe1b Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 10/52] Add versioned standalone generator helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Generators/DurableTaskSourceGenerator.cs | 176 ++++++++- .../VersionedOrchestratorTests.cs | 367 ++++++++++++++++++ 2 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 test/Generators.Tests/VersionedOrchestratorTests.cs diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index b4b2de97c..10e629f44 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -50,6 +50,11 @@ public class DurableTaskSourceGenerator : IIncrementalGenerator /// const string InvalidEventNameDiagnosticId = "DURABLE3002"; + /// + /// Diagnostic ID for duplicate standalone orchestrator logical name + version combinations. + /// + const string DuplicateStandaloneOrchestratorVersionDiagnosticId = "DURABLE3003"; + static readonly DiagnosticDescriptor InvalidTaskNameRule = new( InvalidTaskNameDiagnosticId, title: "Invalid task name", @@ -66,6 +71,14 @@ public class DurableTaskSourceGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + static readonly DiagnosticDescriptor DuplicateStandaloneOrchestratorVersionRule = new( + DuplicateStandaloneOrchestratorVersionDiagnosticId, + title: "Duplicate standalone orchestrator logical name and version", + messageFormat: "The standalone orchestrator 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); + /// public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -214,7 +227,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) taskNameLocation = expression.GetLocation(); } - return new DurableTaskTypeInfo(className, classNamespace, taskName, inputType, outputType, kind, taskNameLocation); + string taskVersion = string.Empty; + foreach (AttributeData attributeData in classType.GetAttributes()) + { + if (attributeData.AttributeClass?.ToDisplayString() == "Microsoft.DurableTask.DurableTaskVersionAttribute" + && attributeData.ConstructorArguments.Length > 0 + && attributeData.ConstructorArguments[0].Value is string version) + { + taskVersion = version; + break; + } + } + + return new DurableTaskTypeInfo(className, classNamespace, taskName, inputType, outputType, kind, taskVersion, taskNameLocation); } static DurableEventTypeInfo? GetDurableEventTypeInfo(GeneratorSyntaxContext context) @@ -338,6 +363,7 @@ static void Execute( IEnumerable validTasks = allTasks .Where(task => IsValidCSharpIdentifier(task.TaskName)); + Dictionary<(string TaskName, string TaskVersion), DurableTaskTypeInfo> standaloneOrchestratorRegistrations = new(); foreach (DurableTaskTypeInfo task in validTasks) { if (task.IsActivity) @@ -350,10 +376,32 @@ static void Execute( } else { + if (!isDurableFunctions) + { + (string TaskName, string TaskVersion) registrationKey = (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); } } + Dictionary standaloneOrchestratorCountsByTaskName = orchestrators + .GroupBy(task => task.TaskName, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + // Filter out events with invalid names List validEvents = allEvents .Where(eventInfo => IsValidCSharpIdentifier(eventInfo.EventName)) @@ -455,6 +503,7 @@ static void Execute( bool hasActivityTriggers = isMicrosoftDurableTask && activityTriggers.Count > 0; bool hasEvents = eventsInNamespace != null && eventsInNamespace.Count > 0; bool hasRegistration = isMicrosoftDurableTask && needsRegistrationMethod; + bool hasVersionedStandaloneHelpers = !isDurableFunctions && orchestratorsInNs.Any(task => !string.IsNullOrEmpty(task.TaskVersion)); if (!hasOrchestratorMethods && !hasActivityMethods && !hasEntityFunctions && !hasActivityTriggers && !hasEvents && !hasRegistration) @@ -485,8 +534,15 @@ public static class GeneratedDurableTaskExtensions AddOrchestratorFunctionDeclaration(sourceBuilder, orchestrator, targetNamespace); } - AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace); - AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace); + string helperSuffix = GetStandaloneOrchestratorHelperSuffix(orchestrator, isDurableFunctions, standaloneOrchestratorCountsByTaskName); + bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(orchestrator.TaskVersion); + AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperSuffix, applyGeneratedVersion); + AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperSuffix, applyGeneratedVersion); + } + + if (hasVersionedStandaloneHelpers) + { + AddStandaloneGeneratedVersionHelperMethods(sourceBuilder); } foreach (DurableTaskTypeInfo activity in activitiesInNs) @@ -612,6 +668,45 @@ static string SimplifyTypeName(string fullyQualifiedTypeName, string targetNames return fullyQualifiedTypeName; } + static string GetStandaloneOrchestratorHelperSuffix(DurableTaskTypeInfo orchestrator, bool isDurableFunctions, IReadOnlyDictionary standaloneOrchestratorCountsByTaskName) + { + if (isDurableFunctions + || string.IsNullOrEmpty(orchestrator.TaskVersion) + || !standaloneOrchestratorCountsByTaskName.TryGetValue(orchestrator.TaskName, out int count) + || count <= 1) + { + return string.Empty; + } + + return ToVersionSuffix(orchestrator.TaskVersion); + } + + static string ToVersionSuffix(string version) + { + if (string.IsNullOrEmpty(version)) + { + return string.Empty; + } + + StringBuilder suffixBuilder = new(version.Length + 1); + suffixBuilder.Append('_'); + foreach (char c in version) + { + if (char.IsLetterOrDigit(c) || c == '_') + { + suffixBuilder.Append(c); + } + else + { + suffixBuilder.Append("_x").Append(((int)c).ToString("X4")).Append('_'); + } + } + + return suffixBuilder.ToString(); + } + + static string ToCSharpStringLiteral(string value) => SymbolDisplay.FormatLiteral(value, quote: true); + static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); @@ -626,7 +721,7 @@ static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, Dura }}"); } - static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) + static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace, string helperSuffix, bool applyGeneratedVersion) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); string inputParameter = inputType + " input"; @@ -636,20 +731,23 @@ static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTy } string simplifiedTypeName = SimplifyTypeName(orchestrator.TypeName, targetNamespace); + string optionsExpression = applyGeneratedVersion + ? $"ApplyGeneratedVersion(options, {ToCSharpStringLiteral(orchestrator.TaskVersion)})" + : "options"; sourceBuilder.AppendLine($@" /// /// Schedules a new instance of the orchestrator. /// /// - public static Task ScheduleNew{orchestrator.TaskName}InstanceAsync( + public static Task ScheduleNew{orchestrator.TaskName}{helperSuffix}InstanceAsync( this IOrchestrationSubmitter client, {inputParameter}, StartOrchestrationOptions? options = null) {{ - return client.ScheduleNewOrchestrationInstanceAsync(""{orchestrator.TaskName}"", input, options); + return client.ScheduleNewOrchestrationInstanceAsync(""{orchestrator.TaskName}"", input, {optionsExpression}); }}"); } - static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) + static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace, string helperSuffix, bool applyGeneratedVersion) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); string outputType = orchestrator.GetOutputTypeForNamespace(targetNamespace); @@ -660,19 +758,76 @@ static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTas } string simplifiedTypeName = SimplifyTypeName(orchestrator.TypeName, targetNamespace); + string optionsExpression = applyGeneratedVersion + ? $"ApplyGeneratedVersion(options, {ToCSharpStringLiteral(orchestrator.TaskVersion)})" + : "options"; sourceBuilder.AppendLine($@" /// /// Calls the sub-orchestrator. /// /// - public static Task<{outputType}> Call{orchestrator.TaskName}Async( + public static Task<{outputType}> Call{orchestrator.TaskName}{helperSuffix}Async( this TaskOrchestrationContext context, {inputParameter}, TaskOptions? options = null) {{ - return context.CallSubOrchestratorAsync<{outputType}>(""{orchestrator.TaskName}"", input, options); + return context.CallSubOrchestratorAsync<{outputType}>(""{orchestrator.TaskName}"", input, {optionsExpression}); }}"); } + static void AddStandaloneGeneratedVersionHelperMethods(StringBuilder sourceBuilder) + { + sourceBuilder.AppendLine(@" + static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) + { + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; + } + + static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) + { + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; + }"); + } + static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace) { string inputType = activity.GetInputTypeForNamespace(targetNamespace); @@ -868,12 +1023,14 @@ public DurableTaskTypeInfo( ITypeSymbol? inputType, ITypeSymbol? outputType, DurableTaskKind kind, + string taskVersion, Location? taskNameLocation = null) { this.TypeName = taskType; this.Namespace = taskNamespace; this.TaskName = taskName; this.Kind = kind; + this.TaskVersion = taskVersion; this.TaskNameLocation = taskNameLocation; this.InputTypeSymbol = inputType; this.OutputTypeSymbol = outputType; @@ -882,6 +1039,7 @@ public DurableTaskTypeInfo( public string TypeName { get; } public string Namespace { get; } public string TaskName { get; } + public string TaskVersion { get; } public DurableTaskKind Kind { get; } public Location? TaskNameLocation { get; } ITypeSymbol? InputTypeSymbol { get; } diff --git a/test/Generators.Tests/VersionedOrchestratorTests.cs b/test/Generators.Tests/VersionedOrchestratorTests.cs new file mode 100644 index 000000000..0e9fea1ca --- /dev/null +++ b/test/Generators.Tests/VersionedOrchestratorTests.cs @@ -0,0 +1,367 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using Microsoft.DurableTask.Generators.Tests.Utils; + +namespace Microsoft.DurableTask.Generators.Tests; + +public class VersionedOrchestratorTests +{ + const string GeneratedClassName = "GeneratedDurableTaskExtensions"; + const string GeneratedFileName = $"{GeneratedClassName}.cs"; + + [Fact] + public Task Standalone_SingleVersionedOrchestrator_GeneratesVersionAwareHelpers() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflow : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflowInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflowAsync( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Standalone_MultiVersionedOrchestrators_GenerateVersionQualifiedHelpersOnly() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v2"")] +class InvoiceWorkflowV2 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflow_v1InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflow_v1Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflow_v2InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflow_v2Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + builder.AddOrchestrator(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Standalone_DuplicateLogicalNameAndVersion_ReportsDiagnostic() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflowV1Duplicate : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflowInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflowAsync( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + return builder; +}"); + + DiagnosticResult expected = new DiagnosticResult("DURABLE3003", DiagnosticSeverity.Error) + .WithSpan("/0/Test0.cs", 12, 14, 12, 31) + .WithArguments("InvoiceWorkflow", "v1"); + + CSharpSourceGeneratorVerifier.Test test = new() + { + TestState = + { + Sources = { code }, + GeneratedSources = + { + (typeof(DurableTaskSourceGenerator), GeneratedFileName, SourceText.From(expectedOutput, System.Text.Encoding.UTF8, SourceHashAlgorithm.Sha256)), + }, + ExpectedDiagnostics = { expected }, + AdditionalReferences = + { + typeof(TaskActivityContext).Assembly, + }, + }, + }; + + return test.RunAsync(); + } +} From a875f47346a9d0bdf7fb464ae098462c611f7e05 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 11/52] Make versioned helper grouping case-insensitive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Generators/DurableTaskSourceGenerator.cs | 13 +- .../VersionedOrchestratorTests.cs | 252 ++++++++++++++++++ 2 files changed, 261 insertions(+), 4 deletions(-) diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 10e629f44..1823abc70 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -363,7 +363,7 @@ static void Execute( IEnumerable validTasks = allTasks .Where(task => IsValidCSharpIdentifier(task.TaskName)); - Dictionary<(string TaskName, string TaskVersion), DurableTaskTypeInfo> standaloneOrchestratorRegistrations = new(); + Dictionary standaloneOrchestratorRegistrations = new(StringComparer.OrdinalIgnoreCase); foreach (DurableTaskTypeInfo task in validTasks) { if (task.IsActivity) @@ -378,7 +378,7 @@ static void Execute( { if (!isDurableFunctions) { - (string TaskName, string TaskVersion) registrationKey = (task.TaskName, task.TaskVersion); + string registrationKey = GetStandaloneOrchestratorRegistrationKey(task.TaskName, task.TaskVersion); if (standaloneOrchestratorRegistrations.ContainsKey(registrationKey)) { Location location = task.TaskNameLocation ?? Location.None; @@ -399,8 +399,8 @@ static void Execute( } Dictionary standaloneOrchestratorCountsByTaskName = orchestrators - .GroupBy(task => task.TaskName, StringComparer.Ordinal) - .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + .GroupBy(task => task.TaskName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); // Filter out events with invalid names List validEvents = allEvents @@ -681,6 +681,11 @@ static string GetStandaloneOrchestratorHelperSuffix(DurableTaskTypeInfo orchestr return ToVersionSuffix(orchestrator.TaskVersion); } + static string GetStandaloneOrchestratorRegistrationKey(string taskName, string taskVersion) + { + return string.Concat(taskName, "\0", taskVersion); + } + static string ToVersionSuffix(string version) { if (string.IsNullOrEmpty(version)) diff --git a/test/Generators.Tests/VersionedOrchestratorTests.cs b/test/Generators.Tests/VersionedOrchestratorTests.cs index 0e9fea1ca..29d14f5e2 100644 --- a/test/Generators.Tests/VersionedOrchestratorTests.cs +++ b/test/Generators.Tests/VersionedOrchestratorTests.cs @@ -227,6 +227,134 @@ public static Task CallInvoiceWorkflow_v2Async( }; } +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + builder.AddOrchestrator(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Standalone_CaseInsensitiveLogicalNameGrouping_GeneratesVersionQualifiedHelpersOnly() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""invoiceworkflow"")] +[DurableTaskVersion(""v2"")] +class InvoiceWorkflowV2 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflow_v1InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflow_v1Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewinvoiceworkflow_v2InstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""invoiceworkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task Callinvoiceworkflow_v2Async( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""invoiceworkflow"", input, ApplyGeneratedVersion(options, ""v2"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) { builder.AddOrchestrator(); @@ -364,4 +492,128 @@ internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistr return test.RunAsync(); } + + [Fact] + public Task Standalone_DuplicateLogicalNameAndVersion_DifferingOnlyByCase_ReportsDiagnostic() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"")] +[DurableTaskVersion(""v1"")] +class InvoiceWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""invoiceworkflow"")] +[DurableTaskVersion(""V1"")] +class InvoiceWorkflowV1Duplicate : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// +public static Task ScheduleNewInvoiceWorkflowInstanceAsync( + this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) +{ + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +/// +/// Calls the sub-orchestrator. +/// +/// +public static Task CallInvoiceWorkflowAsync( + this TaskOrchestrationContext context, int input, TaskOptions? options = null) +{ + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddOrchestrator(); + return builder; +}"); + + DiagnosticResult expected = new DiagnosticResult("DURABLE3003", DiagnosticSeverity.Error) + .WithSpan("/0/Test0.cs", 12, 14, 12, 31) + .WithArguments("invoiceworkflow", "V1"); + + CSharpSourceGeneratorVerifier.Test test = new() + { + TestState = + { + Sources = { code }, + GeneratedSources = + { + (typeof(DurableTaskSourceGenerator), GeneratedFileName, SourceText.From(expectedOutput, System.Text.Encoding.UTF8, SourceHashAlgorithm.Sha256)), + }, + ExpectedDiagnostics = { expected }, + AdditionalReferences = + { + typeof(TaskActivityContext).Assembly, + }, + }, + }; + + return test.RunAsync(); + } } From f6d25a0a1eb97ba10dc76ff4b76f1e3fbcdeefc1 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 12/52] Add Azure Functions duplicate orchestrator diagnostic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Generators/DurableTaskSourceGenerator.cs | 36 +++++++++++++ test/Generators.Tests/AzureFunctionsTests.cs | 56 ++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 1823abc70..dc82aba23 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -55,6 +55,11 @@ public class DurableTaskSourceGenerator : IIncrementalGenerator /// const string DuplicateStandaloneOrchestratorVersionDiagnosticId = "DURABLE3003"; + /// + /// Diagnostic ID for Azure Functions orchestrator logical name collisions. + /// + const string DuplicateAzureFunctionsOrchestratorNameDiagnosticId = "DURABLE3004"; + static readonly DiagnosticDescriptor InvalidTaskNameRule = new( InvalidTaskNameDiagnosticId, title: "Invalid task name", @@ -79,6 +84,14 @@ public class DurableTaskSourceGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + static readonly DiagnosticDescriptor DuplicateAzureFunctionsOrchestratorNameRule = new( + DuplicateAzureFunctionsOrchestratorNameDiagnosticId, + title: "Azure Functions multi-version orchestrators are not supported", + messageFormat: "Azure Functions projects cannot generate multiple orchestrators with the durable task name '{0}'. Use the standalone worker or keep a single logical orchestrator per name.", + category: "DurableTask.Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + /// public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -398,6 +411,29 @@ static void Execute( } } + if (isDurableFunctions) + { + HashSet collidingAzureFunctionsOrchestrators = new( + 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(); + } + Dictionary standaloneOrchestratorCountsByTaskName = orchestrators .GroupBy(task => task.TaskName, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); diff --git a/test/Generators.Tests/AzureFunctionsTests.cs b/test/Generators.Tests/AzureFunctionsTests.cs index ac2d81992..948d6f11c 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,59 @@ 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(); + } + /// /// Verifies that using the class-based syntax for authoring entities generates /// function triggers for Azure Functions. From 99d81a8fca4299eba68f7b5c82fe96bb5d6e1394 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 13/52] chore: clean generator diagnostics metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Generators/AnalyzerReleases.Unshipped.md | 2 ++ src/Generators/DurableTaskSourceGenerator.cs | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Generators/AnalyzerReleases.Unshipped.md b/src/Generators/AnalyzerReleases.Unshipped.md index bee547b6d..ab1eea59d 100644 --- a/src/Generators/AnalyzerReleases.Unshipped.md +++ b/src/Generators/AnalyzerReleases.Unshipped.md @@ -7,3 +7,5 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- DURABLE3001 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when a task name in [DurableTask] attribute is not a valid C# identifier. Task names must start with a letter or underscore and contain only letters, digits, and underscores. DURABLE3002 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when an event name in [DurableEvent] attribute is not a valid C# identifier. Event names must start with a letter or underscore and contain only letters, digits, and underscores. +DURABLE3003 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when a standalone project declares the same orchestrator logical name and version more than once. +DURABLE3004 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when an Azure Functions project declares multiple class-based orchestrators with the same logical durable task name. diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index dc82aba23..984e2bc2f 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; @@ -704,7 +706,7 @@ static string SimplifyTypeName(string fullyQualifiedTypeName, string targetNames return fullyQualifiedTypeName; } - static string GetStandaloneOrchestratorHelperSuffix(DurableTaskTypeInfo orchestrator, bool isDurableFunctions, IReadOnlyDictionary standaloneOrchestratorCountsByTaskName) + static string GetStandaloneOrchestratorHelperSuffix(DurableTaskTypeInfo orchestrator, bool isDurableFunctions, Dictionary standaloneOrchestratorCountsByTaskName) { if (isDurableFunctions || string.IsNullOrEmpty(orchestrator.TaskVersion) @@ -739,7 +741,7 @@ static string ToVersionSuffix(string version) } else { - suffixBuilder.Append("_x").Append(((int)c).ToString("X4")).Append('_'); + suffixBuilder.Append("_x").Append(((int)c).ToString("X4", CultureInfo.InvariantCulture)).Append('_'); } } From 179fff0f8c88d4d7a096c2200e20a53001970668 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 14/52] fix: detect mixed azure functions collisions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureFunctions/SyntaxNodeUtility.cs | 2 +- src/Generators/DurableTaskSourceGenerator.cs | 15 ++++- test/Generators.Tests/AzureFunctionsTests.cs | 65 +++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) 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 984e2bc2f..5cc1a59d2 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -415,11 +415,20 @@ static void Execute( if (isDurableFunctions) { + HashSet existingAzureFunctionsOrchestratorNames = new( + allFunctions + .Where(function => function.Kind == DurableFunctionKind.Orchestration) + .Select(function => function.Name), + StringComparer.OrdinalIgnoreCase); + HashSet collidingAzureFunctionsOrchestrators = new( orchestrators - .GroupBy(task => task.TaskName, StringComparer.OrdinalIgnoreCase) - .Where(group => group.Count() > 1) - .SelectMany(group => group)); + .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) { diff --git a/test/Generators.Tests/AzureFunctionsTests.cs b/test/Generators.Tests/AzureFunctionsTests.cs index 948d6f11c..fc412a068 100644 --- a/test/Generators.Tests/AzureFunctionsTests.cs +++ b/test/Generators.Tests/AzureFunctionsTests.cs @@ -472,6 +472,71 @@ class PaymentWorkflowV2 : TaskOrchestrator 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. From 5f9c5f4c2eef4b937b5ef377cc2ac4638c57036c Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 15/52] test: add versioned class syntax coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 29 +++++ src/Worker/Core/Worker.csproj | 4 + .../VersionedClassSyntaxIntegrationTests.cs | 111 ++++++++++++++++++ .../VersionedClassSyntaxTestOrchestration.cs | 66 +++++++++++ 4 files changed, 210 insertions(+) create mode 100644 test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs create mode 100644 test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs diff --git a/README.md b/README.md index 7226f2011..3558fa766 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,35 @@ 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 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`, and migrate long-running instances between implementations by calling `context.ContinueAsNew(new ContinueAsNewOptions { NewVersion = "vNext", ... })`. + +```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") }); +``` + +Azure Functions currently does **not** support same-name multi-version class-based orchestrators. When the source generator sees multiple class-based orchestrators with the same durable task name in an Azure Functions project, it now emits a diagnostic instead of generating ambiguous bindings. + ### 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. diff --git a/src/Worker/Core/Worker.csproj b/src/Worker/Core/Worker.csproj index daed82b15..c4832a162 100644 --- a/src/Worker/Core/Worker.csproj +++ b/src/Worker/Core/Worker.csproj @@ -19,6 +19,10 @@ The worker is responsible for processing durable task work items. + + + + diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs new file mode 100644 index 000000000..c6465fd6b --- /dev/null +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Client; +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 starting without a version fails when only versioned handlers are registered. + /// + [Fact] + public async Task ClassBasedVersionedOrchestrator_WithoutVersionFailsWhenOnlyVersionedHandlersExist() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddOrchestrator(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "VersionedClassSyntax", + input: 5, + this.TimeoutToken); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Failed, metadata.RuntimeStatus); + Assert.NotNull(metadata.FailureDetails); + Assert.Equal("OrchestratorTaskNotFound", metadata.FailureDetails.ErrorType); + Assert.Contains("No orchestrator task named 'VersionedClassSyntax' was found.", metadata.FailureDetails.ErrorMessage); + } + + /// + /// Verifies continue-as-new can migrate a class-based orchestration to a newer version. + /// + [Fact] + public async Task ClassBasedVersionedOrchestrator_ContinueAsNewNewVersionRoutesToNewClass() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddOrchestrator(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "VersionedContinueAsNewClassSyntax", + input: 4, + new StartOrchestrationOptions + { + Version = new TaskVersion("v1"), + }); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("v2:5", metadata.ReadOutputAs()); + } +} diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs new file mode 100644 index 000000000..defbcd4f0 --- /dev/null +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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 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}"); + } +} From cccce855fb517b56735bd821f2c3a4cb9a72911f Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:22 -0700 Subject: [PATCH 16/52] fix: preserve unversioned version dispatch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 10 +++++++--- src/Worker/Core/DurableTaskFactory.cs | 9 +++++++++ .../DurableTaskFactoryVersioningTests.cs | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3558fa766..e2a860f9b 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,9 @@ 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 +### 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`, and migrate long-running instances between implementations by calling `context.ContinueAsNew(new ContinueAsNewOptions { NewVersion = "vNext", ... })`. +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")] @@ -182,7 +182,11 @@ string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( new StartOrchestrationOptions { Version = new TaskVersion("v2") }); ``` -Azure Functions currently does **not** support same-name multi-version class-based orchestrators. When the source generator sees multiple class-based orchestrators with the same durable task name in an Azure Functions project, it now emits a diagnostic instead of generating ambiguous bindings. +Use `ContinueAsNewOptions.NewVersion` to migrate long-running orchestrations at a replay-safe boundary. + +> Do not combine per-orchestrator `[DurableTaskVersion]` routing with `DurableTaskWorkerOptions.Versioning` (or `UseVersioning(...)`). Both features use the orchestration instance version field, so worker-level version checks can reject per-orchestrator versions before class-based routing occurs. +> +> Azure Functions projects do not support same-name multi-version class-based orchestrators in v1. The source generator reports a diagnostic instead of generating colliding triggers. ### Compatibility with Durable Functions in-process diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 8b3ff5cbd..8ce7fe814 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -61,6 +61,15 @@ public bool TryCreateOrchestrator( return true; } + // Unversioned registrations remain the compatibility fallback when a caller requests a version but the + // logical orchestrator has not opted into per-version handlers. + if (!string.IsNullOrWhiteSpace(version.Version) + && this.orchestrators.TryGetValue(new OrchestratorVersionKey(name, default(TaskVersion)), out factory)) + { + orchestrator = factory.Invoke(serviceProvider); + return true; + } + orchestrator = null; return false; } diff --git a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs index 5664c1764..96d4a4b77 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -46,6 +46,26 @@ public void TryCreateOrchestrator_WithoutMatchingVersion_ReturnsFalse() orchestrator.Should().BeNull(); } + [Fact] + public void TryCreateOrchestrator_WithRequestedVersion_UsesUnversionedRegistrationWhenAvailable() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v2"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + [Fact] public void PublicTryCreateOrchestrator_UsesUnversionedRegistrationOnly() { From 09fc7f2e990ba37771015e1c4b762cda1ae59673 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 17/52] test: cover mixed orchestrator version fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskFactory.cs | 4 +- .../DurableTaskFactoryVersioningTests.cs | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 8ce7fe814..b3db73425 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -61,8 +61,8 @@ public bool TryCreateOrchestrator( return true; } - // Unversioned registrations remain the compatibility fallback when a caller requests a version but the - // logical orchestrator has not opted into per-version handlers. + // Unversioned registrations remain the compatibility fallback when a caller requests a version that has + // no exact match for the logical orchestrator name. if (!string.IsNullOrWhiteSpace(version.Version) && this.orchestrators.TryGetValue(new OrchestratorVersionKey(name, default(TaskVersion)), out factory)) { diff --git a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs index 96d4a4b77..b737866cb 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -66,6 +66,50 @@ public void TryCreateOrchestrator_WithRequestedVersion_UsesUnversionedRegistrati orchestrator.Should().BeOfType(); } + [Fact] + public void TryCreateOrchestrator_WithMixedRegistrations_PrefersExactVersionMatch() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v1"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + + [Fact] + public void TryCreateOrchestrator_WithMixedRegistrations_UsesUnversionedFallbackForUnknownVersion() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v3"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + [Fact] public void PublicTryCreateOrchestrator_UsesUnversionedRegistrationOnly() { From b90aef5b22ecbafe9676c98b0ed0ddea9588e127 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 18/52] docs: add versioning sample design spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...2026-04-01-dts-versioning-sample-design.md | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-01-dts-versioning-sample-design.md diff --git a/docs/superpowers/specs/2026-04-01-dts-versioning-sample-design.md b/docs/superpowers/specs/2026-04-01-dts-versioning-sample-design.md new file mode 100644 index 000000000..16d520e32 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-dts-versioning-sample-design.md @@ -0,0 +1,206 @@ +## DTS versioning sample design + +### Goal + +Add a new sample app under `samples/` that runs against the Durable Task Scheduler (DTS) emulator and demonstrates both versioning approaches supported by this repo: + +1. **Worker-level versioning** via `UseVersioning(...)` +2. **Per-orchestrator versioning** via `[DurableTaskVersion]` + +### Assumption + +The user did not answer the clarification prompt, so this design assumes “both versioning approaches” means the two approaches above. This matches the current repo capabilities and the recently implemented per-orchestration versioning work. + +## Approaches considered + +### Approach 1 — One console sample with two sequential demos (**recommended**) + +Create a single console app that: + +1. runs a **worker-level versioning** demo first +2. then runs a **per-orchestrator versioning** demo + +Each demo starts its own worker/client host against the same DTS emulator connection string and prints the results to the console. + +**Pros** +- Matches the request for a single sample app +- Makes the comparison between the two approaches explicit +- Keeps DTS emulator setup and README instructions simple +- Fits the repo’s existing console-sample patterns + +**Cons** +- The sample has more code than a single-focus sample +- The worker-level demo and per-orchestrator demo must be kept visually separated to avoid confusion + +### Approach 2 — One console sample with command-line modes + +Create one sample app with subcommands such as `worker-level` and `per-orchestrator`. + +**Pros** +- Strong separation of concerns +- Easier to explain each path independently + +**Cons** +- More ceremony for a sample that should be easy to run +- Users must rerun or pass arguments to see the full story + +### Approach 3 — Two separate sample apps + +Create one DTS sample for worker-level versioning and another for per-orchestrator versioning. + +**Pros** +- Simplest code per sample +- Each sample stays narrowly focused + +**Cons** +- Conflicts with the “sample app” request +- Duplicates emulator setup, host wiring, and documentation +- Makes it harder to compare the two approaches side-by-side + +## Decision + +Use **Approach 1**: one DTS emulator console sample with two sequential demos. + +This is the clearest way to teach the contrast: + +- **worker-level versioning** is for rolling a single logical implementation per worker run +- **per-orchestrator versioning** is for keeping multiple orchestrator implementations for the same logical name active in one worker process + +## Sample structure + +### New sample + +- `samples/VersioningSample/VersioningSample.csproj` +- `samples/VersioningSample/Program.cs` +- `samples/VersioningSample/README.md` + +### Existing files to update + +- `Microsoft.DurableTask.sln` — add the new sample project +- `README.md` — add a short DTS sample reference in the Durable Task Scheduler section + +## Programming model + +### Shared sample shape + +The sample will: + +- use `HostApplicationBuilder` +- read `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` from configuration +- configure both `AddDurableTaskClient(...UseDurableTaskScheduler(...))` and `AddDurableTaskWorker(...UseDurableTaskScheduler(...))` +- start the host, run the demos, print results, and stop the host + +The sample will target `net8.0;net10.0`, matching the newer DTS console sample pattern. + +### Demo 1 — Worker-level versioning + +This demo will show that worker-level versioning is **host-scoped**, not multi-version-in-one-process. + +Design: + +1. Start a host configured with: + - an unversioned orchestration registration for a logical name such as `WorkerLevelGreeting` + - `UseVersioning(new DurableTaskWorkerOptions.VersioningOptions { Version = "1.0", MatchStrategy = Strict, FailureStrategy = Fail, DefaultVersion = "1.0" })` +2. Schedule and complete an instance using version `1.0` +3. Stop the host +4. Start a second host with the same logical orchestration name but a different implementation and worker version `2.0` +5. Schedule and complete an instance using version `2.0` + +The sample output should make the lesson explicit: **worker-level versioning upgrades the worker deployment; it does not keep multiple implementations of the same orchestration active in one worker process**. + +Implementation note: + +- To avoid class-name and source-generator collisions, this demo should use explicit manual registrations (`AddOrchestratorFunc(...)` or equivalent) rather than multiple same-name unversioned `[DurableTask]` classes in the same project. + +### Demo 2 — Per-orchestrator versioning + +This demo will show that `[DurableTaskVersion]` allows multiple implementations of the same logical orchestration name to coexist in one worker process. + +Design: + +1. Define two class-based orchestrators with the same `[DurableTask("OrderWorkflow")]` name and distinct `[DurableTaskVersion("v1")]` / `[DurableTaskVersion("v2")]` values +2. Register them together using generated `AddAllGeneratedTasks()` +3. Start one instance with version `v1` +4. Start another instance with version `v2` +5. Run a small migration example that starts on `v1` and calls `ContinueAsNew(new ContinueAsNewOptions { NewVersion = "v2", ... })` + +The sample output should show: + +- `v1` routed to the `v1` implementation +- `v2` routed to the `v2` implementation +- `ContinueAsNewOptions.NewVersion` migrating a long-running orchestration at a replay-safe boundary + +Implementation note: + +- This demo should use class-based syntax and the source generator because `[DurableTaskVersion]` is part of the new feature being taught. + +## Code organization + +To align with the repo’s sample guidance, the sample should stay compact and readable: + +- one `Program.cs` +- top-of-file comments explaining the two demos +- helper methods such as `RunWorkerLevelVersioningDemoAsync(...)` and `RunPerOrchestratorVersioningDemoAsync(...)` +- task and activity classes placed at the bottom of the file + +## README content + +The sample README should include: + +1. What the sample demonstrates +2. The distinction between worker-level and per-orchestrator versioning +3. DTS emulator startup instructions: + + ```bash + docker run --name durabletask-emulator -d -p 8080:8080 -e ASPNETCORE_URLS=http://+:8080 mcr.microsoft.com/dts/dts-emulator:latest + ``` + +4. Connection string setup: + + ```bash + export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" + ``` + +5. Run instructions: + + ```bash + dotnet run --project samples/VersioningSample/VersioningSample.csproj + ``` + +6. A short explanation of when to choose each versioning approach +7. A note that per-orchestrator `[DurableTaskVersion]` routing should not be combined with worker-level `UseVersioning(...)` in the same worker path because both use the orchestration instance version field + +## Error handling and UX + +The sample should fail fast when `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` is missing. + +Console output should clearly label: + +- when the sample is running the worker-level demo +- when it is running the per-orchestrator demo +- which version completed +- why the two approaches are different + +## Verification + +Implementation verification should include: + +1. `dotnet build samples/VersioningSample/VersioningSample.csproj` +2. `dotnet run --project samples/VersioningSample/VersioningSample.csproj` against a running DTS emulator +3. confirmation that the sample prints successful results for: + - worker-level `1.0` + - worker-level `2.0` + - per-orchestrator `v1` + - per-orchestrator `v2` + - per-orchestrator migration `v1 -> v2` + +## Scope boundaries + +This sample will **not**: + +- attempt to demonstrate Azure Functions multi-version routing +- add automated sample tests +- demonstrate every version-match strategy +- mix worker-level versioning and per-orchestrator versioning inside the same running worker path + +The sample is educational, not exhaustive. From 91d6edea938c997b495b0aef7cee790e496d332f Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 19/52] docs: add DTS versioning sample plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plans/2026-04-01-dts-versioning-sample.md | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-01-dts-versioning-sample.md diff --git a/docs/superpowers/plans/2026-04-01-dts-versioning-sample.md b/docs/superpowers/plans/2026-04-01-dts-versioning-sample.md new file mode 100644 index 000000000..c8eacb854 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-dts-versioning-sample.md @@ -0,0 +1,418 @@ +# DTS Versioning Sample Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new DTS emulator console sample that demonstrates worker-level versioning and per-orchestrator `[DurableTaskVersion]` routing in one runnable app. + +**Architecture:** Create a single `samples/VersioningSample` console app with two sequential demos. The first demo uses manual orchestration registration plus `UseVersioning(...)` to show worker-scoped versioning; the second demo uses class-based orchestrators plus `[DurableTaskVersion]` and generated registration to show same-name multi-version routing and `ContinueAsNewOptions.NewVersion` migration. + +**Tech Stack:** .NET console app, `HostApplicationBuilder`, `Microsoft.DurableTask.Client.AzureManaged`, `Microsoft.DurableTask.Worker.AzureManaged`, `Microsoft.DurableTask.Generators`, DTS emulator via `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` + +--- + +### File map + +- Create: `samples/VersioningSample/VersioningSample.csproj` — sample project definition +- Create: `samples/VersioningSample/Program.cs` — both demos, helper methods, and sample task types +- Create: `samples/VersioningSample/README.md` — emulator setup, run instructions, and explanation of both approaches +- Modify: `Microsoft.DurableTask.sln` — include the new sample project +- Modify: `README.md` — add a short reference to the new DTS sample in the Durable Task Scheduler section + +### Task 1: Scaffold the sample project and implement the worker-level versioning demo + +**Files:** +- Create: `samples/VersioningSample/VersioningSample.csproj` +- Create: `samples/VersioningSample/Program.cs` + +- [ ] **Step 1: Write the failing sample shell** + +```xml + + + + + Exe + net8.0;net10.0 + enable + + + + + + + + + + + + + + +``` + +```csharp +// samples/VersioningSample/Program.cs +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +await RunWorkerLevelVersioningDemoAsync(builder); +``` + +- [ ] **Step 2: Run build to verify it fails** + +Run: `dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal` +Expected: FAIL with a compile error for `RunWorkerLevelVersioningDemoAsync` + +- [ ] **Step 3: Write the minimal worker-level demo implementation** + +```csharp +// samples/VersioningSample/Program.cs +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates the two versioning models supported by durabletask-dotnet +// when connected directly to the Durable Task Scheduler (DTS) emulator. + +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); + +string schedulerConnectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException("DURABLE_TASK_SCHEDULER_CONNECTION_STRING is not set."); + +await RunWorkerLevelVersioningDemoAsync(schedulerConnectionString); + +static async Task RunWorkerLevelVersioningDemoAsync(string schedulerConnectionString) +{ + Console.WriteLine("=== Worker-level versioning ==="); + + string v1Result = await RunWorkerScopedVersionAsync( + schedulerConnectionString, + workerVersion: "1.0", + outputPrefix: "worker-v1"); + Console.WriteLine($"Worker version 1.0 completed with output: {v1Result}"); + + string v2Result = await RunWorkerScopedVersionAsync( + schedulerConnectionString, + workerVersion: "2.0", + outputPrefix: "worker-v2"); + Console.WriteLine($"Worker version 2.0 completed with output: {v2Result}"); + + Console.WriteLine("Worker-level versioning keeps one implementation active per worker run."); +} + +static async Task RunWorkerScopedVersionAsync( + string schedulerConnectionString, + string workerVersion, + string outputPrefix) +{ + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Services.AddDurableTaskClient(clientBuilder => + { + clientBuilder.UseDurableTaskScheduler(schedulerConnectionString); + clientBuilder.UseDefaultVersion(workerVersion); + }); + + builder.Services.AddDurableTaskWorker(workerBuilder => + { + workerBuilder.AddTasks(tasks => + { + tasks.AddOrchestratorFunc("WorkerLevelGreeting", (context, input) => + Task.FromResult($"{outputPrefix}:{context.Version}:{input}")); + }); + workerBuilder.UseDurableTaskScheduler(schedulerConnectionString); + workerBuilder.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions + { + Version = workerVersion, + DefaultVersion = workerVersion, + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + FailureStrategy = DurableTaskWorkerOptions.VersionFailureStrategy.Fail, + }); + }); + + IHost host = builder.Build(); + await host.StartAsync(); + + DurableTaskClient client = host.Services.GetRequiredService(); + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + "WorkerLevelGreeting", + input: "hello", + new StartOrchestrationOptions { Version = workerVersion }); + OrchestrationMetadata metadata = await client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true); + string output = metadata.ReadOutputAs()!; + + await host.StopAsync(); + return output; +} +``` + +- [ ] **Step 4: Run build to verify the worker-level demo compiles** + +Run: `dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add samples/VersioningSample/VersioningSample.csproj samples/VersioningSample/Program.cs +git commit -m "feat: add DTS worker versioning sample skeleton" +``` + +### Task 2: Add the per-orchestrator versioning demo to the same sample + +**Files:** +- Modify: `samples/VersioningSample/Program.cs` + +- [ ] **Step 1: Write the failing per-orchestrator demo calls** + +```csharp +// Insert below the worker-level demo call in Program.cs +await RunPerOrchestratorVersioningDemoAsync(schedulerConnectionString); + +// Insert below RunWorkerLevelVersioningDemoAsync +static async Task RunPerOrchestratorVersioningDemoAsync(string schedulerConnectionString) +{ + using IHost host = BuildPerOrchestratorHost(schedulerConnectionString); + await host.StartAsync(); + + DurableTaskClient client = host.Services.GetRequiredService(); + string v1InstanceId = await client.ScheduleNewOrderWorkflow_v1InstanceAsync(5); + string v2InstanceId = await client.ScheduleNewOrderWorkflow_v2InstanceAsync(5); + string migrationInstanceId = await client.ScheduleNewMigratingOrderWorkflow_v1InstanceAsync(4); +} +``` + +- [ ] **Step 2: Run build to verify it fails** + +Run: `dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal` +Expected: FAIL because `BuildPerOrchestratorHost`, `OrderWorkflow` types, and generated helper methods do not exist yet + +- [ ] **Step 3: Write the per-orchestrator versioning implementation** + +```csharp +// Add to samples/VersioningSample/Program.cs +using Microsoft.DurableTask.Client; + +await RunPerOrchestratorVersioningDemoAsync(schedulerConnectionString); + +static async Task RunPerOrchestratorVersioningDemoAsync(string schedulerConnectionString) +{ + Console.WriteLine("=== Per-orchestrator versioning ==="); + + using IHost host = BuildPerOrchestratorHost(schedulerConnectionString); + await host.StartAsync(); + + DurableTaskClient client = host.Services.GetRequiredService(); + + string v1InstanceId = await client.ScheduleNewOrderWorkflow_v1InstanceAsync(5); + OrchestrationMetadata v1 = await client.WaitForInstanceCompletionAsync(v1InstanceId, getInputsAndOutputs: true); + Console.WriteLine($"OrderWorkflow v1 output: {v1.ReadOutputAs()}"); + + string v2InstanceId = await client.ScheduleNewOrderWorkflow_v2InstanceAsync(5); + OrchestrationMetadata v2 = await client.WaitForInstanceCompletionAsync(v2InstanceId, getInputsAndOutputs: true); + Console.WriteLine($"OrderWorkflow v2 output: {v2.ReadOutputAs()}"); + + string migrationInstanceId = await client.ScheduleNewMigratingOrderWorkflow_v1InstanceAsync(4); + OrchestrationMetadata migration = await client.WaitForInstanceCompletionAsync(migrationInstanceId, getInputsAndOutputs: true); + Console.WriteLine($"Migrating workflow output: {migration.ReadOutputAs()}"); + + await host.StopAsync(); +} + +static IHost BuildPerOrchestratorHost(string schedulerConnectionString) +{ + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Services.AddDurableTaskClient(clientBuilder => clientBuilder.UseDurableTaskScheduler(schedulerConnectionString)); + builder.Services.AddDurableTaskWorker(workerBuilder => + { + workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + workerBuilder.UseDurableTaskScheduler(schedulerConnectionString); + }); + + return builder.Build(); +} + +[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}"); +} + +[DurableTask("MigratingOrderWorkflow")] +[DurableTaskVersion("v1")] +public sealed class MigratingOrderWorkflowV1 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) + { + context.ContinueAsNew(new ContinueAsNewOptions + { + NewInput = input + 1, + NewVersion = "v2", + }); + + return Task.FromResult(string.Empty); + } +} + +[DurableTask("MigratingOrderWorkflow")] +[DurableTaskVersion("v2")] +public sealed class MigratingOrderWorkflowV2 : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) + => Task.FromResult($"v2:{input}"); +} +``` + +- [ ] **Step 4: Run build to verify the full sample compiles** + +Run: `dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal` +Expected: PASS + +- [ ] **Step 5: Run against the DTS emulator** + +Run: `dotnet run --project samples/VersioningSample/VersioningSample.csproj` +Expected: output includes: +- `Worker version 1.0 completed with output: worker-v1:1.0:hello` +- `Worker version 2.0 completed with output: worker-v2:2.0:hello` +- `OrderWorkflow v1 output: v1:5` +- `OrderWorkflow v2 output: v2:5` +- `Migrating workflow output: v2:5` + +- [ ] **Step 6: Commit** + +```bash +git add samples/VersioningSample/Program.cs +git commit -m "feat: demonstrate per-orchestrator DTS versioning" +``` + +### Task 3: Document the sample and wire it into the repo + +**Files:** +- Create: `samples/VersioningSample/README.md` +- Modify: `Microsoft.DurableTask.sln` +- Modify: `README.md` + +- [ ] **Step 1: Write the sample README** + +````md +# DTS Versioning Sample + +This sample demonstrates the two versioning models available when you run durabletask-dotnet directly against the Durable Task Scheduler (DTS) emulator: + +1. **Worker-level versioning** via `UseVersioning(...)` +2. **Per-orchestrator versioning** via `[DurableTaskVersion]` + +## Run 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 +``` + +## Configure the connection string + +```bash +export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" +``` + +## Run the sample + +```bash +dotnet run --project samples/VersioningSample/VersioningSample.csproj +``` + +## What to look for + +- The worker-level demo runs one implementation per worker version (`1.0`, then `2.0`) +- The per-orchestrator demo keeps `v1` and `v2` of the same logical orchestration active in one worker process +- The migration demo uses `ContinueAsNewOptions.NewVersion` to move from `v1` to `v2` + +> Do not combine `[DurableTaskVersion]` routing with worker-level `UseVersioning(...)` in the same worker path. Both features use the orchestration instance version field. +```` + +- [ ] **Step 2: Add the sample to the solution** + +Run: + +```bash +dotnet sln Microsoft.DurableTask.sln add samples/VersioningSample/VersioningSample.csproj +``` + +Expected: `Project 'samples/VersioningSample/VersioningSample.csproj' added to the solution.` + +- [ ] **Step 3: Add a short root README reference** + +```md + + +For a runnable DTS emulator example that compares worker-level versioning with per-orchestrator `[DurableTaskVersion]` routing, see [samples/VersioningSample](samples/VersioningSample/README.md). +``` + +- [ ] **Step 4: Run final sample build verification** + +Run: `dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add samples/VersioningSample/README.md Microsoft.DurableTask.sln README.md +git commit -m "docs: add DTS versioning sample" +``` + +### Task 4: Final verification + +**Files:** +- Verify only; no new files + +- [ ] **Step 1: Run the focused sample verification** + +Run: + +```bash +dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal && \ +dotnet run --project samples/VersioningSample/VersioningSample.csproj +``` + +Expected: +- build succeeds +- console output shows both worker-level and per-orchestrator demo results + +- [ ] **Step 2: Run impacted versioning coverage** + +Run: + +```bash +dotnet test test/Worker/Core.Tests/Worker.Tests.csproj --filter "DurableTaskFactoryVersioningTests|UseWorkItemFiltersTests" --nologo --verbosity minimal && \ +dotnet test test/Generators.Tests/Generators.Tests.csproj --filter "VersionedOrchestratorTests|AzureFunctionsTests" --nologo --verbosity minimal && \ +dotnet test test/Grpc.IntegrationTests/Grpc.IntegrationTests.csproj --filter "VersionedClassSyntaxIntegrationTests|OrchestrationVersionPassedThroughContext|OrchestrationVersioning_MatchTypeNotSpecified_NoVersionFailure|OrchestrationVersioning_MatchTypeNone_NoVersionFailure|OrchestrationVersioning_MatchTypeCurrentOrOlder_VersionSuccess|SubOrchestrationInheritsDefaultVersion|OrchestrationTaskVersionOverridesDefaultVersion|SubOrchestrationTaskVersionOverridesDefaultVersion|ContinueAsNewWithNewVersion" --nologo --verbosity minimal +``` + +Expected: PASS across all targeted worker, generator, and gRPC integration tests + +- [ ] **Step 3: Commit any verification-only adjustments** + +```bash +git add -A +git commit -m "chore: finalize DTS versioning sample" +``` From 047aea9603db166d26f0e955f2ea4d113c91b3b5 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 20/52] samples: add worker-level versioning demo Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/VersioningSample/Program.cs | 91 +++++++++++++++++++ .../VersioningSample/VersioningSample.csproj | 20 ++++ 2 files changed, 111 insertions(+) create mode 100644 samples/VersioningSample/Program.cs create mode 100644 samples/VersioningSample/VersioningSample.csproj diff --git a/samples/VersioningSample/Program.cs b/samples/VersioningSample/Program.cs new file mode 100644 index 000000000..1dab00578 --- /dev/null +++ b/samples/VersioningSample/Program.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates worker-level versioning by running the same orchestration name +// against two separate worker versions. + +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); + +string schedulerConnectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException("DURABLE_TASK_SCHEDULER_CONNECTION_STRING is not set."); + +await RunWorkerLevelVersioningDemoAsync(schedulerConnectionString); + +async Task RunWorkerLevelVersioningDemoAsync(string schedulerConnectionString) +{ + await RunWorkerScopedVersionAsync(schedulerConnectionString, "1.0", "Version 1 implementation"); + await RunWorkerScopedVersionAsync(schedulerConnectionString, "2.0", "Version 2 implementation"); + + Console.WriteLine("Worker-level versioning keeps one implementation active per worker run."); +} + +async Task RunWorkerScopedVersionAsync(string schedulerConnectionString, string workerVersion, string outputPrefix) +{ + HostApplicationBuilder scopedBuilder = Host.CreateApplicationBuilder(); + + scopedBuilder.Services.AddDurableTaskClient(clientBuilder => + { + clientBuilder.UseDurableTaskScheduler(schedulerConnectionString); + clientBuilder.UseDefaultVersion(workerVersion); + }); + + scopedBuilder.Services.AddDurableTaskWorker(workerBuilder => + { + workerBuilder.AddTasks(tasks => + { + tasks.AddOrchestratorFunc("WorkerLevelGreeting", (context, name) => + context.CallActivityAsync("FormatWorkerGreeting", name)); + tasks.AddActivityFunc("FormatWorkerGreeting", (context, name) => + $"{outputPrefix} says hello to {name} on worker version {workerVersion}."); + }); + + workerBuilder.UseDurableTaskScheduler(schedulerConnectionString); + workerBuilder.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions + { + Version = workerVersion, + DefaultVersion = workerVersion, + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + FailureStrategy = DurableTaskWorkerOptions.VersionFailureStrategy.Fail, + }); + }); + + IHost host = scopedBuilder.Build(); + await host.StartAsync(); + + try + { + await using DurableTaskClient client = host.Services.GetRequiredService(); + + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + "WorkerLevelGreeting", + "Durable Task", + new StartOrchestrationOptions { Version = workerVersion }); + + OrchestrationMetadata completedInstance = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true); + + if (completedInstance.RuntimeStatus != OrchestrationRuntimeStatus.Completed) + { + throw new InvalidOperationException($"Worker version {workerVersion} completed with unexpected status {completedInstance.RuntimeStatus}."); + } + + string output = completedInstance.ReadOutputAs() + ?? throw new InvalidOperationException($"Worker version {workerVersion} did not produce output."); + + Console.WriteLine($"Worker version {workerVersion} completed with output: {output}"); + } + finally + { + await host.StopAsync(); + } +} diff --git a/samples/VersioningSample/VersioningSample.csproj b/samples/VersioningSample/VersioningSample.csproj new file mode 100644 index 000000000..7b960196d --- /dev/null +++ b/samples/VersioningSample/VersioningSample.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0;net10.0 + enable + + + + + + + + + + + + + + From 378969932292b3b167edc1a266e1781cc2c4cb5f Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 21/52] feat: add DTS versioning sample apps Split into two focused samples: - WorkerVersioningSample: deployment-based versioning with UseDefaultVersion() - PerOrchestratorVersioningSample: multi-version routing with [DurableTaskVersion] Both tested against the DTS emulator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.DurableTask.sln | 40 +++++++- README.md | 2 + .../PerOrchestratorVersioningSample.csproj | 25 +++++ .../Program.cs | 99 +++++++++++++++++++ .../PerOrchestratorVersioningSample/README.md | 67 +++++++++++++ samples/VersioningSample/Program.cs | 91 ----------------- samples/WorkerVersioningSample/Program.cs | 74 ++++++++++++++ samples/WorkerVersioningSample/README.md | 56 +++++++++++ .../WorkerVersioningSample.csproj} | 5 +- 9 files changed, 364 insertions(+), 95 deletions(-) create mode 100644 samples/PerOrchestratorVersioningSample/PerOrchestratorVersioningSample.csproj create mode 100644 samples/PerOrchestratorVersioningSample/Program.cs create mode 100644 samples/PerOrchestratorVersioningSample/README.md delete mode 100644 samples/VersioningSample/Program.cs create mode 100644 samples/WorkerVersioningSample/Program.cs create mode 100644 samples/WorkerVersioningSample/README.md rename samples/{VersioningSample/VersioningSample.csproj => WorkerVersioningSample/WorkerVersioningSample.csproj} (78%) diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 0b8ef9359..10a2b64bc 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,14 @@ 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -701,7 +709,30 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -759,7 +790,10 @@ 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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/README.md b/README.md index e2a860f9b..ebea641de 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,8 @@ The Durable Task Scheduler for Azure Functions is a managed backend that is curr 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) and [PerOrchestratorVersioningSample](samples/PerOrchestratorVersioningSample/README.md) (multi-version routing with `[DurableTaskVersion]`). + ## Obtaining the Protobuf definitions This project utilizes protobuf definitions from [durabletask-protobuf](https://github.com/microsoft/durabletask-protobuf), which are copied (vendored) into this repository under the `src/Grpc` directory. See the corresponding [README.md](./src/Grpc/README.md) for more information about how to update the protobuf definitions. 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..ca61d5e90 --- /dev/null +++ b/samples/PerOrchestratorVersioningSample/Program.cs @@ -0,0 +1,99 @@ +// 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. +builder.Services.AddDurableTaskWorker(wb => +{ + wb.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + wb.UseDurableTaskScheduler(connectionString); +}); + +// Configure the client. +builder.Services.AddDurableTaskClient(cb => cb.UseDurableTaskScheduler(connectionString)); + +IHost host = builder.Build(); +await host.StartAsync(); + +await using DurableTaskClient client = host.Services.GetRequiredService(); + +Console.WriteLine("=== Per-orchestrator versioning ([DurableTaskVersion]) ==="); +Console.WriteLine(); + +// 1) Schedule an OrderWorkflow version 1 instance. +// The generated helper ScheduleNewOrderWorkflow_1InstanceAsync automatically +// stamps the instance with version "1". +Console.WriteLine("Scheduling OrderWorkflow v1 ..."); +string v1Id = await client.ScheduleNewOrderWorkflow_1InstanceAsync(5); +OrchestrationMetadata v1 = await client.WaitForInstanceCompletionAsync(v1Id, getInputsAndOutputs: true); +Console.WriteLine($" Result: {v1.ReadOutputAs()}"); +Console.WriteLine(); + +// 2) Schedule an OrderWorkflow version 2 instance — same logical name, different logic. +Console.WriteLine("Scheduling OrderWorkflow v2 ..."); +string v2Id = await client.ScheduleNewOrderWorkflow_2InstanceAsync(5); +OrchestrationMetadata v2 = await client.WaitForInstanceCompletionAsync(v2Id, getInputsAndOutputs: true); +Console.WriteLine($" Result: {v2.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Done! Both versions ran in the same worker process."); +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)"); + } +} diff --git a/samples/PerOrchestratorVersioningSample/README.md b/samples/PerOrchestratorVersioningSample/README.md new file mode 100644 index 000000000..680ed7d2a --- /dev/null +++ b/samples/PerOrchestratorVersioningSample/README.md @@ -0,0 +1,67 @@ +# Per-Orchestrator Versioning Sample + +This sample demonstrates per-orchestrator versioning with `[DurableTaskVersion]`, where multiple implementations of the same logical orchestration name coexist in one worker process. + +## What it shows + +- Two classes share the same `[DurableTask("OrderWorkflow")]` name but have different `[DurableTaskVersion]` values +- The source generator produces version-qualified helpers like `ScheduleNewOrderWorkflow_1InstanceAsync()` and `ScheduleNewOrderWorkflow_2InstanceAsync()` +- `AddAllGeneratedTasks()` registers both versions automatically +- Each instance is routed to the correct implementation based on its version + +## Prerequisites + +- .NET 8.0 or 10.0 SDK +- [Docker](https://www.docker.com/get-started) + +## Running the Sample + +### 1. Start the DTS emulator + +```bash +docker run --name durabletask-emulator -d -p 8080:8080 -e ASPNETCORE_URLS=http://+:8080 mcr.microsoft.com/dts/dts-emulator:latest +``` + +### 2. Set the connection string + +```bash +export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" +``` + +### 3. Run the sample + +```bash +dotnet run +``` + +Expected output: + +``` +=== Per-orchestrator versioning ([DurableTaskVersion]) === + +Scheduling OrderWorkflow v1 ... + Result: Order total: $50 (v1 — no discount) + +Scheduling OrderWorkflow v2 ... + Result: Order total: $40 (v2 — with discount) + +Done! Both versions ran in the same worker process. +``` + +### 4. Clean up + +```bash +docker rm -f durabletask-emulator +``` + +## When to use this approach + +Per-orchestrator versioning is useful when: + +- You need multiple versions of the same orchestration active simultaneously +- You want version-specific routing without deploying separate workers +- You're building a system where callers choose which version to invoke + +For simpler deployment-based versioning, see the [WorkerVersioningSample](../WorkerVersioningSample/README.md). + +> **Note:** Do not combine `[DurableTaskVersion]` routing with worker-level `UseVersioning()` in the same worker. Both features use the orchestration instance version field. diff --git a/samples/VersioningSample/Program.cs b/samples/VersioningSample/Program.cs deleted file mode 100644 index 1dab00578..000000000 --- a/samples/VersioningSample/Program.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// This sample demonstrates worker-level versioning by running the same orchestration name -// against two separate worker versions. - -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); - -string schedulerConnectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? throw new InvalidOperationException("DURABLE_TASK_SCHEDULER_CONNECTION_STRING is not set."); - -await RunWorkerLevelVersioningDemoAsync(schedulerConnectionString); - -async Task RunWorkerLevelVersioningDemoAsync(string schedulerConnectionString) -{ - await RunWorkerScopedVersionAsync(schedulerConnectionString, "1.0", "Version 1 implementation"); - await RunWorkerScopedVersionAsync(schedulerConnectionString, "2.0", "Version 2 implementation"); - - Console.WriteLine("Worker-level versioning keeps one implementation active per worker run."); -} - -async Task RunWorkerScopedVersionAsync(string schedulerConnectionString, string workerVersion, string outputPrefix) -{ - HostApplicationBuilder scopedBuilder = Host.CreateApplicationBuilder(); - - scopedBuilder.Services.AddDurableTaskClient(clientBuilder => - { - clientBuilder.UseDurableTaskScheduler(schedulerConnectionString); - clientBuilder.UseDefaultVersion(workerVersion); - }); - - scopedBuilder.Services.AddDurableTaskWorker(workerBuilder => - { - workerBuilder.AddTasks(tasks => - { - tasks.AddOrchestratorFunc("WorkerLevelGreeting", (context, name) => - context.CallActivityAsync("FormatWorkerGreeting", name)); - tasks.AddActivityFunc("FormatWorkerGreeting", (context, name) => - $"{outputPrefix} says hello to {name} on worker version {workerVersion}."); - }); - - workerBuilder.UseDurableTaskScheduler(schedulerConnectionString); - workerBuilder.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions - { - Version = workerVersion, - DefaultVersion = workerVersion, - MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, - FailureStrategy = DurableTaskWorkerOptions.VersionFailureStrategy.Fail, - }); - }); - - IHost host = scopedBuilder.Build(); - await host.StartAsync(); - - try - { - await using DurableTaskClient client = host.Services.GetRequiredService(); - - string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( - "WorkerLevelGreeting", - "Durable Task", - new StartOrchestrationOptions { Version = workerVersion }); - - OrchestrationMetadata completedInstance = await client.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs: true); - - if (completedInstance.RuntimeStatus != OrchestrationRuntimeStatus.Completed) - { - throw new InvalidOperationException($"Worker version {workerVersion} completed with unexpected status {completedInstance.RuntimeStatus}."); - } - - string output = completedInstance.ReadOutputAs() - ?? throw new InvalidOperationException($"Worker version {workerVersion} did not produce output."); - - Console.WriteLine($"Worker version {workerVersion} completed with output: {output}"); - } - finally - { - await host.StopAsync(); - } -} diff --git a/samples/WorkerVersioningSample/Program.cs b/samples/WorkerVersioningSample/Program.cs new file mode 100644 index 000000000..9e4d0eace --- /dev/null +++ b/samples/WorkerVersioningSample/Program.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates worker-level versioning. Each worker deployment is pinned +// to a single version string via UseDefaultVersion(). The client stamps new orchestration +// instances with that version. To upgrade, you deploy a new worker binary with the +// updated implementation. +// +// This sample registers a single orchestration ("GreetingWorkflow") and shows how +// the version is associated with the orchestration instance. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// Read the DTS connection string from configuration. +string connectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException( + "Set DURABLE_TASK_SCHEDULER_CONNECTION_STRING. " + + "For the local emulator: Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"); + +// The worker version represents a deployment version. In production, you'd change this +// when deploying a new version of your worker with updated orchestration logic. +string workerVersion = builder.Configuration.GetValue("WORKER_VERSION") ?? "1.0"; + +// Configure the worker with an orchestration. +builder.Services.AddDurableTaskWorker(wb => +{ + wb.AddTasks(tasks => + { + tasks.AddOrchestratorFunc( + "GreetingWorkflow", + (ctx, name) => Task.FromResult($"Hello, {name}! (worker version: {ctx.Version})")); + }); + + wb.UseDurableTaskScheduler(connectionString); +}); + +// Configure the client. UseDefaultVersion stamps every new orchestration instance +// with this version automatically — no need to set it per-request. +builder.Services.AddDurableTaskClient(cb => +{ + cb.UseDurableTaskScheduler(connectionString); + cb.UseDefaultVersion(workerVersion); +}); + +IHost host = builder.Build(); +await host.StartAsync(); + +await using DurableTaskClient client = host.Services.GetRequiredService(); + +Console.WriteLine($"=== Worker-level versioning (version: {workerVersion}) ==="); +Console.WriteLine(); + +// Schedule a greeting orchestration. The version is automatically stamped by the client. +string instanceId = await client.ScheduleNewOrchestrationInstanceAsync("GreetingWorkflow", "World"); +Console.WriteLine($"Started orchestration: {instanceId}"); + +OrchestrationMetadata result = await client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true); +Console.WriteLine($"Status: {result.RuntimeStatus}"); +Console.WriteLine($"Output: {result.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Try running again with WORKER_VERSION=2.0 to simulate a deployment upgrade."); + +await host.StopAsync(); diff --git a/samples/WorkerVersioningSample/README.md b/samples/WorkerVersioningSample/README.md new file mode 100644 index 000000000..506575c53 --- /dev/null +++ b/samples/WorkerVersioningSample/README.md @@ -0,0 +1,56 @@ +# Worker-Level Versioning Sample + +This sample demonstrates worker-level versioning, where each worker deployment is associated with a single version string. + +## What it shows + +- The client uses `UseDefaultVersion()` to stamp every new orchestration instance with a version +- The orchestration reads `context.Version` to see what version it was scheduled with +- To "upgrade," you redeploy the worker with a new implementation and change the version string + +## Prerequisites + +- .NET 8.0 or 10.0 SDK +- [Docker](https://www.docker.com/get-started) + +## Running the Sample + +### 1. Start the DTS emulator + +```bash +docker run --name durabletask-emulator -d -p 8080:8080 -e ASPNETCORE_URLS=http://+:8080 mcr.microsoft.com/dts/dts-emulator:latest +``` + +### 2. Set the connection string + +```bash +export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" +``` + +### 3. Run with version 1.0 (default) + +```bash +dotnet run +``` + +### 4. Simulate a deployment upgrade to version 2.0 + +```bash +WORKER_VERSION=2.0 dotnet run +``` + +### 5. Clean up + +```bash +docker rm -f durabletask-emulator +``` + +## When to use this approach + +Worker-level versioning is the simplest model. Use it when: + +- You deploy one version of your orchestration logic at a time +- You want a straightforward rolling upgrade story +- You don't need multiple versions of the same orchestration active simultaneously + +For running multiple versions of the same orchestration in one worker, see the [PerOrchestratorVersioningSample](../PerOrchestratorVersioningSample/README.md). diff --git a/samples/VersioningSample/VersioningSample.csproj b/samples/WorkerVersioningSample/WorkerVersioningSample.csproj similarity index 78% rename from samples/VersioningSample/VersioningSample.csproj rename to samples/WorkerVersioningSample/WorkerVersioningSample.csproj index 7b960196d..e19bb7314 100644 --- a/samples/VersioningSample/VersioningSample.csproj +++ b/samples/WorkerVersioningSample/WorkerVersioningSample.csproj @@ -11,10 +11,13 @@ + - + From 433412b8cca9d67c9771ad5cf823fdc623d862b3 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 22/52] feat: add ContinueAsNew migration demo with loop guard The v1 orchestrator uses an AlreadyMigrated flag in the input to prevent infinite ContinueAsNew loops if the backend does not propagate NewVersion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Program.cs | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/samples/PerOrchestratorVersioningSample/Program.cs b/samples/PerOrchestratorVersioningSample/Program.cs index ca61d5e90..738abd690 100644 --- a/samples/PerOrchestratorVersioningSample/Program.cs +++ b/samples/PerOrchestratorVersioningSample/Program.cs @@ -24,7 +24,7 @@ "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. +// class in the project — including both versions of OrderWorkflow and MigratingWorkflow. builder.Services.AddDurableTaskWorker(wb => { wb.AddTasks(tasks => tasks.AddAllGeneratedTasks()); @@ -59,6 +59,18 @@ Console.WriteLine(); Console.WriteLine("Done! Both versions ran in the same worker process."); +Console.WriteLine(); + +// 3) Demonstrate ContinueAsNew version migration: the v1 orchestration migrates +// to v2 using ContinueAsNewOptions.NewVersion. This is the safest migration point +// for eternal orchestrations because the history is fully reset. +Console.WriteLine("Scheduling MigratingWorkflow v1 → v2 (ContinueAsNew migration) ..."); +string migrateId = await client.ScheduleNewMigratingWorkflow_1InstanceAsync(new MigrationInput(10)); +OrchestrationMetadata migrate = await client.WaitForInstanceCompletionAsync(migrateId, getInputsAndOutputs: true); +Console.WriteLine($" Result: {migrate.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Sample completed successfully!"); await host.StopAsync(); // ───────────────────────────────────────────────────────────────────────────── @@ -97,3 +109,55 @@ public override Task RunAsync(TaskOrchestrationContext context, int item 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); From a1932377a7cef8a6c2c624518dea9017cfe08fa9 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 23/52] feat: add ActivityVersionKey and version-aware activity registry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/ActivityVersionKey.cs | 65 +++++++++++++ .../DurableTaskRegistry.Activities.cs | 45 +++++++-- src/Abstractions/DurableTaskRegistry.cs | 34 ++++--- .../DurableTaskVersionAttribute.cs | 10 +- .../DurableTaskRegistryVersioningTests.cs | 96 +++++++++++++++++++ 5 files changed, 226 insertions(+), 24 deletions(-) create mode 100644 src/Abstractions/ActivityVersionKey.cs diff --git a/src/Abstractions/ActivityVersionKey.cs b/src/Abstractions/ActivityVersionKey.cs new file mode 100644 index 000000000..072f7875e --- /dev/null +++ b/src/Abstractions/ActivityVersionKey.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Represents the logical name and version of a registered activity. +/// +internal readonly struct ActivityVersionKey : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The activity name. + /// The activity version. + public ActivityVersionKey(TaskName name, TaskVersion version) + : this(name.Name, version.Version) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The activity name. + /// The activity version. + public ActivityVersionKey(string name, string? version) + { + this.Name = Check.NotNullOrEmpty(name, nameof(name)); + this.Version = version ?? string.Empty; + } + + /// + /// Gets the logical activity name. + /// + public string Name { get; } + + /// + /// Gets the activity version. + /// + public string Version { get; } + + /// + /// Determines whether the specified key is equal to the current key. + /// + /// The key to compare with the current key. + /// true if the keys are equal; otherwise false. + public bool Equals(ActivityVersionKey other) + { + return string.Equals(this.Name, other.Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(this.Version, other.Version, StringComparison.OrdinalIgnoreCase); + } + + /// + public override bool Equals(object? obj) => obj is ActivityVersionKey other && this.Equals(other); + + /// + public override int GetHashCode() + { + unchecked + { + return (StringComparer.OrdinalIgnoreCase.GetHashCode(this.Name) * 397) + ^ StringComparer.OrdinalIgnoreCase.GetHashCode(this.Version); + } + } +} diff --git a/src/Abstractions/DurableTaskRegistry.Activities.cs b/src/Abstractions/DurableTaskRegistry.Activities.cs index ac525147a..f4c4e898e 100644 --- a/src/Abstractions/DurableTaskRegistry.Activities.cs +++ b/src/Abstractions/DurableTaskRegistry.Activities.cs @@ -32,6 +32,27 @@ TaskName ITaskActivity singleton Action{Context} */ + /// + /// Registers an activity factory. + /// + /// The name of the activity. + /// The activity version. + /// The activity factory. + /// This registry instance, for call chaining. + /// + /// Thrown if any of the following are true: + /// + /// If is default. + /// If and are already registered. + /// If is null. + /// + /// + public DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func factory) + { + Check.NotNull(factory); + return this.AddActivity(name, version, _ => factory()); + } + /// /// Registers an activity factory, resolving the provided type with the service provider. /// @@ -41,7 +62,10 @@ TaskName ITaskActivity singleton public DurableTaskRegistry AddActivity(TaskName name, Type type) { Check.ConcreteType(type); - return this.AddActivity(name, sp => (ITaskActivity)ActivatorUtilities.GetServiceOrCreateInstance(sp, type)); + return this.AddActivity( + name, + type.GetDurableTaskVersion(), + sp => (ITaskActivity)ActivatorUtilities.GetServiceOrCreateInstance(sp, type)); } /// @@ -51,7 +75,13 @@ public DurableTaskRegistry AddActivity(TaskName name, Type type) /// The activity type. /// The same registry, for call chaining. public DurableTaskRegistry AddActivity(Type type) - => this.AddActivity(type.GetTaskName(), type); + { + Check.ConcreteType(type); + return this.AddActivity( + type.GetTaskName(), + type.GetDurableTaskVersion(), + sp => (ITaskActivity)ActivatorUtilities.GetServiceOrCreateInstance(sp, type)); + } /// /// Registers an activity factory, resolving the provided type with the service provider. @@ -77,23 +107,26 @@ public DurableTaskRegistry AddActivity() /// Registers an activity singleton. /// /// The name of the activity to register. - /// The orchestration instance to use. + /// The activity instance to use. /// The same registry, for call chaining. public DurableTaskRegistry AddActivity(TaskName name, ITaskActivity activity) { Check.NotNull(activity); - return this.AddActivity(name, (IServiceProvider _) => activity); + return this.AddActivity(name, activity.GetType().GetDurableTaskVersion(), () => activity); } /// /// Registers an activity singleton. /// - /// The orchestration instance to use. + /// The activity instance to use. /// The same registry, for call chaining. public DurableTaskRegistry AddActivity(ITaskActivity activity) { Check.NotNull(activity); - return this.AddActivity(activity.GetType().GetTaskName(), activity); + return this.AddActivity( + activity.GetType().GetTaskName(), + activity.GetType().GetDurableTaskVersion(), + () => activity); } /// diff --git a/src/Abstractions/DurableTaskRegistry.cs b/src/Abstractions/DurableTaskRegistry.cs index 5ec14583f..7937618bb 100644 --- a/src/Abstractions/DurableTaskRegistry.cs +++ b/src/Abstractions/DurableTaskRegistry.cs @@ -16,8 +16,8 @@ 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. @@ -46,17 +46,7 @@ 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 entity factory. @@ -84,4 +74,22 @@ public DurableTaskRegistry AddEntity(TaskName name, Func factory) + { + Check.NotDefault(name); + Check.NotNull(factory); + + ActivityVersionKey key = new(name, version); + if (this.Activities.ContainsKey(key)) + { + 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.Activities.Add(key, factory); + return this; + } } diff --git a/src/Abstractions/DurableTaskVersionAttribute.cs b/src/Abstractions/DurableTaskVersionAttribute.cs index 1919c376b..a8c9108b8 100644 --- a/src/Abstractions/DurableTaskVersionAttribute.cs +++ b/src/Abstractions/DurableTaskVersionAttribute.cs @@ -4,11 +4,11 @@ namespace Microsoft.DurableTask; /// -/// Indicates the version of a class-based durable orchestrator. +/// Indicates the version of a class-based durable orchestrator or activity. /// /// -/// This attribute is only consumed for orchestrator registrations and source generation. -/// Activities and entities ignore this attribute in v1. +/// 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 @@ -16,14 +16,14 @@ public sealed class DurableTaskVersionAttribute : Attribute /// /// Initializes a new instance of the class. /// - /// The version string for the orchestrator. + /// The version string for the orchestrator or activity. public DurableTaskVersionAttribute(string? version = null) { this.Version = string.IsNullOrEmpty(version) ? default : new TaskVersion(version!); } /// - /// Gets the orchestrator version declared on the attributed class. + /// Gets the durable task version declared on the attributed class. /// public TaskVersion Version { get; } } diff --git a/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs b/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs index 290457084..2164fe12f 100644 --- a/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs +++ b/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs @@ -56,6 +56,57 @@ public void AddOrchestrator_ExplicitVersionFactory_SameLogicalNameDifferentVersi 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 @@ -88,6 +139,38 @@ public override Task RunAsync(TaskOrchestrationContext context, string i => 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; @@ -100,4 +183,17 @@ public ManualWorkflow(string 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); + } } From 96f80d9a9f3a555f676341718d797ad4eab5c03c Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 24/52] feat: version-aware activity dispatch and work-item filters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskFactory.cs | 26 ++++++++++--- .../Core/DurableTaskWorkerWorkItemFilters.cs | 37 +++++++++++++++---- src/Worker/Core/IVersionedActivityFactory.cs | 26 +++++++++++++ .../Grpc/GrpcDurableTaskWorker.Processor.cs | 17 ++++++++- 4 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 src/Worker/Core/IVersionedActivityFactory.cs diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index b3db73425..b5374e097 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -9,9 +9,9 @@ namespace Microsoft.DurableTask.Worker; /// /// A factory for creating orchestrators and activities. /// -sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedOrchestratorFactory +sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedActivityFactory, IVersionedOrchestratorFactory { - readonly IDictionary> activities; + readonly IDictionary> activities; readonly IDictionary> orchestrators; readonly IDictionary> entities; @@ -22,7 +22,7 @@ sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedOrchestratorFa /// The orchestrator factories. /// The entity factories. internal DurableTaskFactory( - IDictionary> activities, + IDictionary> activities, IDictionary> orchestrators, IDictionary> entities) { @@ -33,10 +33,21 @@ internal DurableTaskFactory( /// 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)) + ActivityVersionKey key = new(name, version); + if (this.activities.TryGetValue(key, out Func? factory)) + { + activity = factory.Invoke(serviceProvider); + return true; + } + + if (!string.IsNullOrWhiteSpace(version.Version) + && this.activities.TryGetValue(new ActivityVersionKey(name, default(TaskVersion)), out factory)) { activity = factory.Invoke(serviceProvider); return true; @@ -46,6 +57,11 @@ 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, diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index 39b6264ba..f450608d0 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -35,10 +35,10 @@ public class DurableTaskWorkerWorkItemFilters /// A new instance of constructed from the provided registry. internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(DurableTaskRegistry registry, DurableTaskWorkerOptions? workerOptions) { - IReadOnlyList activityVersions = []; + IReadOnlyList workerActivityVersions = []; if (workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict) { - activityVersions = [workerOptions.Versioning.Version]; + workerActivityVersions = [workerOptions.Versioning.Version]; } // Orchestration filters now group registrations by logical name. Version lists are only emitted when every @@ -64,14 +64,37 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable }) .ToList(); + List activityFilters = registry.Activities + .GroupBy(activity => activity.Key.Name, StringComparer.OrdinalIgnoreCase) + .Select(group => + { + bool hasUnversionedRegistration = group.Any(entry => string.IsNullOrWhiteSpace(entry.Key.Version)); + IReadOnlyList registeredVersions = group.Select(entry => entry.Key.Version) + .Where(version => !string.IsNullOrWhiteSpace(version)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + IReadOnlyList versions = hasUnversionedRegistration && workerActivityVersions.Count == 0 + ? [] + : registeredVersions + .Concat(workerActivityVersions) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new ActivityFilter + { + Name = group.Key, + Versions = versions, + }; + }) + .ToList(); + return new DurableTaskWorkerWorkItemFilters { Orchestrations = orchestrationFilters, - Activities = registry.Activities.Select(activity => new ActivityFilter - { - Name = activity.Key, - Versions = activityVersions, - }).ToList(), + Activities = activityFilters, Entities = registry.Entities.Select(entity => new EntityFilter { // Entity names are normalized to lowercase in the backend. diff --git a/src/Worker/Core/IVersionedActivityFactory.cs b/src/Worker/Core/IVersionedActivityFactory.cs new file mode 100644 index 000000000..f32336618 --- /dev/null +++ b/src/Worker/Core/IVersionedActivityFactory.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.DurableTask.Worker; + +/// +/// Creates activities by exact logical name and version. +/// +internal interface IVersionedActivityFactory +{ + /// + /// Tries to create an activity that matches the provided logical name and version. + /// + /// The activity name. + /// The activity version. + /// The service provider. + /// 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/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index 0916ddba6..9a36fb602 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -802,7 +802,17 @@ 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 IVersionedActivityFactory 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. @@ -811,10 +821,13 @@ async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, } else { + string versionText = requestedVersion.Version ?? string.Empty; failureDetails = new P.TaskFailureDetails { ErrorType = "ActivityTaskNotFound", - ErrorMessage = $"No activity task named '{name}' was found.", + ErrorMessage = string.IsNullOrEmpty(versionText) || this.worker.Factory is not IVersionedActivityFactory + ? $"No activity task named '{name}' was found." + : $"No activity task named '{name}' with version '{versionText}' was found.", IsNonRetriable = true, }; } From d973a65b8e0ff47ad6cfae6b5810db4c30a65ed9 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 25/52] feat: complete activity versioning support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Generators/AnalyzerReleases.Unshipped.md | 4 +- src/Generators/DurableTaskSourceGenerator.cs | 85 +++++-- .../Core/DurableTaskWorkerWorkItemFilters.cs | 20 +- src/Worker/Core/IVersionedActivityFactory.cs | 3 +- .../VersionedActivityTests.cs | 228 ++++++++++++++++++ .../UseWorkItemFiltersTests.cs | 88 ++++++- ...rableTaskFactoryActivityVersioningTests.cs | 132 ++++++++++ 7 files changed, 524 insertions(+), 36 deletions(-) create mode 100644 test/Generators.Tests/VersionedActivityTests.cs create mode 100644 test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs diff --git a/src/Generators/AnalyzerReleases.Unshipped.md b/src/Generators/AnalyzerReleases.Unshipped.md index ab1eea59d..5e333b839 100644 --- a/src/Generators/AnalyzerReleases.Unshipped.md +++ b/src/Generators/AnalyzerReleases.Unshipped.md @@ -7,5 +7,5 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- DURABLE3001 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when a task name in [DurableTask] attribute is not a valid C# identifier. Task names must start with a letter or underscore and contain only letters, digits, and underscores. DURABLE3002 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when an event name in [DurableEvent] attribute is not a valid C# identifier. Event names must start with a letter or underscore and contain only letters, digits, and underscores. -DURABLE3003 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when a standalone project declares the same orchestrator logical name and version more than once. -DURABLE3004 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when an Azure Functions project declares multiple class-based orchestrators with the same logical durable task name. +DURABLE3003 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when a standalone project declares the same orchestrator or activity logical name and version more than once. +DURABLE3004 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Reports when an Azure Functions project declares multiple class-based orchestrators or activities with the same logical durable task name. diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 5cc1a59d2..82d677d6c 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -53,12 +53,12 @@ public class DurableTaskSourceGenerator : IIncrementalGenerator const string InvalidEventNameDiagnosticId = "DURABLE3002"; /// - /// Diagnostic ID for duplicate standalone orchestrator logical name + version combinations. + /// Diagnostic ID for duplicate standalone orchestrator or activity logical name + version combinations. /// const string DuplicateStandaloneOrchestratorVersionDiagnosticId = "DURABLE3003"; /// - /// Diagnostic ID for Azure Functions orchestrator logical name collisions. + /// Diagnostic ID for Azure Functions orchestrator or activity logical name collisions. /// const string DuplicateAzureFunctionsOrchestratorNameDiagnosticId = "DURABLE3004"; @@ -80,16 +80,16 @@ public class DurableTaskSourceGenerator : IIncrementalGenerator static readonly DiagnosticDescriptor DuplicateStandaloneOrchestratorVersionRule = new( DuplicateStandaloneOrchestratorVersionDiagnosticId, - title: "Duplicate standalone orchestrator logical name and version", - messageFormat: "The standalone orchestrator logical name '{0}' with version '{1}' is declared more than once. Each logical name and version combination must be unique.", + 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 orchestrators are not supported", - messageFormat: "Azure Functions projects cannot generate multiple orchestrators with the durable task name '{0}'. Use the standalone worker or keep a single logical orchestrator per name.", + 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); @@ -379,10 +379,29 @@ static void Execute( .Where(task => IsValidCSharpIdentifier(task.TaskName)); Dictionary standaloneOrchestratorRegistrations = new(StringComparer.OrdinalIgnoreCase); + Dictionary standaloneActivityRegistrations = new(StringComparer.OrdinalIgnoreCase); foreach (DurableTaskTypeInfo task in validTasks) { if (task.IsActivity) { + if (!isDurableFunctions) + { + string registrationKey = GetStandaloneTaskRegistrationKey(task.TaskName, task.TaskVersion); + if (standaloneActivityRegistrations.ContainsKey(registrationKey)) + { + Location location = task.TaskNameLocation ?? Location.None; + Diagnostic diagnostic = Diagnostic.Create( + DuplicateStandaloneOrchestratorVersionRule, + location, + task.TaskName, + task.TaskVersion); + context.ReportDiagnostic(diagnostic); + continue; + } + + standaloneActivityRegistrations.Add(registrationKey, task); + } + activities.Add(task); } else if (task.IsEntity) @@ -393,7 +412,7 @@ static void Execute( { if (!isDurableFunctions) { - string registrationKey = GetStandaloneOrchestratorRegistrationKey(task.TaskName, task.TaskVersion); + string registrationKey = GetStandaloneTaskRegistrationKey(task.TaskName, task.TaskVersion); if (standaloneOrchestratorRegistrations.ContainsKey(registrationKey)) { Location location = task.TaskNameLocation ?? Location.None; @@ -443,12 +462,45 @@ static void Execute( 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)) @@ -581,7 +633,7 @@ public static class GeneratedDurableTaskExtensions AddOrchestratorFunctionDeclaration(sourceBuilder, orchestrator, targetNamespace); } - string helperSuffix = GetStandaloneOrchestratorHelperSuffix(orchestrator, isDurableFunctions, standaloneOrchestratorCountsByTaskName); + string helperSuffix = GetStandaloneTaskHelperSuffix(orchestrator, isDurableFunctions, standaloneOrchestratorCountsByTaskName); bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(orchestrator.TaskVersion); AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperSuffix, applyGeneratedVersion); AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperSuffix, applyGeneratedVersion); @@ -594,7 +646,8 @@ public static class GeneratedDurableTaskExtensions foreach (DurableTaskTypeInfo activity in activitiesInNs) { - AddActivityCallMethod(sourceBuilder, activity, targetNamespace); + string helperSuffix = GetStandaloneTaskHelperSuffix(activity, isDurableFunctions, standaloneActivityCountsByTaskName); + AddActivityCallMethod(sourceBuilder, activity, targetNamespace, helperSuffix); if (isDurableFunctions) { @@ -715,20 +768,20 @@ static string SimplifyTypeName(string fullyQualifiedTypeName, string targetNames return fullyQualifiedTypeName; } - static string GetStandaloneOrchestratorHelperSuffix(DurableTaskTypeInfo orchestrator, bool isDurableFunctions, Dictionary standaloneOrchestratorCountsByTaskName) + static string GetStandaloneTaskHelperSuffix(DurableTaskTypeInfo task, bool isDurableFunctions, Dictionary standaloneTaskCountsByTaskName) { if (isDurableFunctions - || string.IsNullOrEmpty(orchestrator.TaskVersion) - || !standaloneOrchestratorCountsByTaskName.TryGetValue(orchestrator.TaskName, out int count) + || string.IsNullOrEmpty(task.TaskVersion) + || !standaloneTaskCountsByTaskName.TryGetValue(task.TaskName, out int count) || count <= 1) { return string.Empty; } - return ToVersionSuffix(orchestrator.TaskVersion); + return ToVersionSuffix(task.TaskVersion); } - static string GetStandaloneOrchestratorRegistrationKey(string taskName, string taskVersion) + static string GetStandaloneTaskRegistrationKey(string taskName, string taskVersion) { return string.Concat(taskName, "\0", taskVersion); } @@ -880,7 +933,7 @@ static void AddStandaloneGeneratedVersionHelperMethods(StringBuilder sourceBuild }"); } - static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace) + static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace, string helperSuffix) { string inputType = activity.GetInputTypeForNamespace(targetNamespace); string outputType = activity.GetOutputTypeForNamespace(targetNamespace); @@ -897,7 +950,7 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeIn /// Calls the activity. /// /// - public static Task<{outputType}> Call{activity.TaskName}Async(this TaskOrchestrationContext ctx, {inputParameter}, TaskOptions? options = null) + public static Task<{outputType}> Call{activity.TaskName}{helperSuffix}Async(this TaskOrchestrationContext ctx, {inputParameter}, TaskOptions? options = null) {{ return ctx.CallActivityAsync<{outputType}>(""{activity.TaskName}"", input, options); }}"); diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index f450608d0..9015926a9 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -35,12 +35,6 @@ public class DurableTaskWorkerWorkItemFilters /// A new instance of constructed from the provided registry. internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(DurableTaskRegistry registry, DurableTaskWorkerOptions? workerOptions) { - IReadOnlyList workerActivityVersions = []; - if (workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict) - { - workerActivityVersions = [workerOptions.Versioning.Version]; - } - // 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. @@ -68,17 +62,13 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable .GroupBy(activity => activity.Key.Name, StringComparer.OrdinalIgnoreCase) .Select(group => { + // Activity filters mirror orchestration filters: any unversioned registration becomes a catch-all + // for that logical name, while fully versioned groups advertise only the explicit versions. bool hasUnversionedRegistration = group.Any(entry => string.IsNullOrWhiteSpace(entry.Key.Version)); - IReadOnlyList registeredVersions = group.Select(entry => entry.Key.Version) - .Where(version => !string.IsNullOrWhiteSpace(version)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - IReadOnlyList versions = hasUnversionedRegistration && workerActivityVersions.Count == 0 + IReadOnlyList versions = hasUnversionedRegistration ? [] - : registeredVersions - .Concat(workerActivityVersions) + : group.Select(entry => entry.Key.Version) + .Where(version => !string.IsNullOrWhiteSpace(version)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) .ToArray(); diff --git a/src/Worker/Core/IVersionedActivityFactory.cs b/src/Worker/Core/IVersionedActivityFactory.cs index f32336618..244835346 100644 --- a/src/Worker/Core/IVersionedActivityFactory.cs +++ b/src/Worker/Core/IVersionedActivityFactory.cs @@ -6,7 +6,8 @@ namespace Microsoft.DurableTask.Worker; /// -/// Creates activities by exact logical name and version. +/// Creates activity instances by logical name and requested version. +/// Implementations may use an unversioned registration as a compatibility fallback when no exact version match exists. /// internal interface IVersionedActivityFactory { diff --git a/test/Generators.Tests/VersionedActivityTests.cs b/test/Generators.Tests/VersionedActivityTests.cs new file mode 100644 index 000000000..cac806867 --- /dev/null +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using Microsoft.DurableTask.Generators.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.DurableTask.Generators.Tests; + +public class VersionedActivityTests +{ + const string GeneratedClassName = "GeneratedDurableTaskExtensions"; + const string GeneratedFileName = $"{GeneratedClassName}.cs"; + + [Fact] + public Task Standalone_SingleVersionedActivity_GeneratesUnsuffixedHelper() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceActivity"")] +[DurableTaskVersion(""v1"")] +class InvoiceActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallInvoiceActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""InvoiceActivity"", input, options); +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddActivity(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Standalone_MultiVersionedActivities_GenerateVersionQualifiedHelpersOnly() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceActivity"")] +[DurableTaskVersion(""v1"")] +class InvoiceActivityV1 : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""InvoiceActivity"")] +[DurableTaskVersion(""v2"")] +class InvoiceActivityV2 : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallInvoiceActivity_v1Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""InvoiceActivity"", input, options); +} + +/// +/// Calls the activity. +/// +/// +public static Task CallInvoiceActivity_v2Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""InvoiceActivity"", input, options); +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddActivity(); + builder.AddActivity(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Standalone_DuplicateLogicalNameAndVersion_ReportsDiagnostic() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceActivity"")] +[DurableTaskVersion(""v1"")] +class InvoiceActivityV1 : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +} + +[DurableTask(""InvoiceActivity"")] +[DurableTaskVersion(""v1"")] +class InvoiceActivityV1Duplicate : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// +public static Task CallInvoiceActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +{ + return ctx.CallActivityAsync(""InvoiceActivity"", input, options); +} + +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/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index 1da7a4e96..3a9f4e3f6 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -181,7 +181,7 @@ public void WorkItemFilters_DefaultNullWithVersioningNone_WhenExplicitlyOptedIn( } [Fact] - public void WorkItemFilters_DefaultVersionWithVersioningStrict_AppliesToActivitiesOnly_WhenExplicitlyOptedIn() + public void WorkItemFilters_DefaultVersionWithVersioningStrict_DoesNotChangeActivityFilters_WhenExplicitlyOptedIn() { // Arrange ServiceCollection services = new(); @@ -211,7 +211,7 @@ public void WorkItemFilters_DefaultVersionWithVersioningStrict_AppliesToActiviti // Assert actual.Orchestrations.Should().ContainSingle(o => o.Name == nameof(TestOrchestrator) && o.Versions.Count == 0); - actual.Activities.Should().ContainSingle(a => a.Name == nameof(TestActivity) && a.Versions.Contains("1.0")); + actual.Activities.Should().ContainSingle(a => a.Name == nameof(TestActivity) && a.Versions.Count == 0); } [Fact] @@ -268,6 +268,60 @@ public void WorkItemFilters_UnversionedAndVersionedOrchestrators_FallBackToNameO 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] public void WorkItemFilters_DefaultEmptyRegistry_ProducesEmptyFilters() { @@ -492,6 +546,36 @@ public override Task RunAsync(TaskActivityContext context, object input) throw new NotImplementedException(); } } + + [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..08d669033 --- /dev/null +++ b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.Tests; + +public class DurableTaskFactoryActivityVersioningTests +{ + [Fact] + public void TryCreateActivity_WithMatchingVersion_ReturnsMatchingImplementation() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + out ITaskActivity? activity); + + // Assert + found.Should().BeTrue(); + activity.Should().BeOfType(); + } + + [Fact] + public void TryCreateActivity_WithoutMatchingVersion_ReturnsFalse() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + out ITaskActivity? activity); + + // Assert + found.Should().BeFalse(); + activity.Should().BeNull(); + } + + [Fact] + public void TryCreateActivity_WithRequestedVersion_UsesUnversionedRegistrationWhenAvailable() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + out ITaskActivity? activity); + + // Assert + found.Should().BeTrue(); + activity.Should().BeOfType(); + } + + [Fact] + public void TryCreateActivity_WithMixedRegistrations_PrefersExactVersionMatch() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + registry.AddActivity(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v1"), + Mock.Of(), + 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(); + } + + [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"); + } +} From 6c0732c8a04a8298dbc6ca7eb10c0cb6fa64108a Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 26/52] feat: add activity version options Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/TaskOptions.cs | 67 +++++++++-- test/Abstractions.Tests/TaskOptionsTests.cs | 119 ++++++++++++++++---- 2 files changed, 150 insertions(+), 36 deletions(-) diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index 7c0d54ee2..297bf43ee 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -8,8 +8,8 @@ namespace Microsoft.DurableTask; /// /// Options that can be used to control the behavior of orchestrator task execution. /// -public record TaskOptions -{ +public record TaskOptions +{ /// /// Initializes a new instance of the class. /// @@ -77,15 +77,60 @@ public TaskOptions(TaskOptions options) /// starting a new sub-orchestration to specify the instance ID. /// /// The instance ID to use. - /// A new . - public SubOrchestrationOptions WithInstanceId(string instanceId) => new(this, instanceId); -} - -/// -/// Options that can be used to control the behavior of orchestrator task execution. This derived type can be used to -/// supply extra options for orchestrations. -/// -public record SubOrchestrationOptions : TaskOptions + /// A new . + public SubOrchestrationOptions WithInstanceId(string instanceId) => new(this, instanceId); +} + +/// +/// Options that can be used to control the behavior of orchestrator task execution. This derived type can be used to +/// supply extra options for activities. +/// +public record ActivityOptions : TaskOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The task retry options. + public ActivityOptions(TaskRetryOptions? retry = null) + : base(retry) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The task options to wrap. + public ActivityOptions(TaskOptions options) + : base(options) + { + if (options is ActivityOptions derived) + { + this.Version = derived.Version; + } + } + + /// + /// Initializes a new instance of the class by copying from another instance. + /// + /// The activity options to copy from. + public ActivityOptions(ActivityOptions options) + : base(options) + { + Check.NotNull(options); + this.Version = options.Version; + } + + /// + /// Gets the version to associate with the activity. + /// + public TaskVersion? Version { get; init; } +} + +/// +/// Options that can be used to control the behavior of orchestrator task execution. This derived type can be used to +/// supply extra options for orchestrations. +/// +public record SubOrchestrationOptions : TaskOptions { /// /// Initializes a new instance of the class. diff --git a/test/Abstractions.Tests/TaskOptionsTests.cs b/test/Abstractions.Tests/TaskOptionsTests.cs index ad5b9c863..2a215efbf 100644 --- a/test/Abstractions.Tests/TaskOptionsTests.cs +++ b/test/Abstractions.Tests/TaskOptionsTests.cs @@ -8,16 +8,21 @@ namespace Microsoft.DurableTask.Tests; public class TaskOptionsTests { [Fact] - public void Empty_Ctors_Okay() - { - TaskOptions options = new(); - options.Retry.Should().BeNull(); - options.Tags.Should().BeNull(); - - SubOrchestrationOptions subOptions = new(); - subOptions.Retry.Should().BeNull(); - subOptions.Tags.Should().BeNull(); - subOptions.InstanceId.Should().BeNull(); + public void Empty_Ctors_Okay() + { + TaskOptions options = new(); + options.Retry.Should().BeNull(); + options.Tags.Should().BeNull(); + + ActivityOptions activityOptions = new(); + activityOptions.Retry.Should().BeNull(); + activityOptions.Tags.Should().BeNull(); + activityOptions.Version.Should().BeNull(); + + SubOrchestrationOptions subOptions = new(); + subOptions.Retry.Should().BeNull(); + subOptions.Tags.Should().BeNull(); + subOptions.InstanceId.Should().BeNull(); StartOrchestrationOptions startOptions = new(); startOptions.Version.Should().BeNull(); @@ -154,11 +159,11 @@ public void WithDedupeStatuses_ConvertsAllEnumValuesToStrings() } [Fact] - public void TaskOptions_CopyConstructor_CopiesAllProperties() - { - // Arrange - RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); - TaskRetryOptions retry = new(policy); + public void TaskOptions_CopyConstructor_CopiesAllProperties() + { + // Arrange + RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); + TaskRetryOptions retry = new(policy); Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; TaskOptions original = new(retry, tags); @@ -166,16 +171,80 @@ public void TaskOptions_CopyConstructor_CopiesAllProperties() TaskOptions copy = new(original); // Assert - copy.Retry.Should().Be(original.Retry); - copy.Tags.Should().BeSameAs(original.Tags); - } - - [Fact] - public void SubOrchestrationOptions_CopyConstructor_CopiesAllProperties() - { - // Arrange - RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); - TaskRetryOptions retry = new(policy); + copy.Retry.Should().Be(original.Retry); + copy.Tags.Should().BeSameAs(original.Tags); + } + + [Fact] + public void ActivityOptions_CopyConstructor_CopiesAllProperties() + { + // Arrange + RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); + TaskRetryOptions retry = new(policy); + Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; + TaskVersion version = new("1.0"); + ActivityOptions original = new(retry) + { + Tags = tags, + Version = version, + }; + + // Act + ActivityOptions copy = new(original); + + // Assert + copy.Retry.Should().Be(original.Retry); + copy.Tags.Should().BeSameAs(original.Tags); + copy.Version.Should().Be(original.Version); + } + + [Fact] + public void ActivityOptions_CopyFromTaskOptions_PreservesRetryAndTagsButLeavesVersionNull() + { + // Arrange + RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); + TaskRetryOptions retry = new(policy); + Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; + TaskOptions original = new(retry, tags); + + // Act + ActivityOptions copy = new(original); + + // Assert + copy.Retry.Should().Be(original.Retry); + copy.Tags.Should().BeSameAs(original.Tags); + copy.Version.Should().BeNull(); + } + + [Fact] + public void ActivityOptions_CopyFromTaskOptions_CopiesVersionWhenSourceIsActivityOptions() + { + // Arrange + RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); + TaskRetryOptions retry = new(policy); + Dictionary tags = new() { { "key1", "value1" } }; + TaskVersion version = new("1.0"); + ActivityOptions original = new(retry) + { + Tags = tags, + Version = version, + }; + + // Act + ActivityOptions copy = new(original as TaskOptions); + + // Assert + copy.Retry.Should().Be(original.Retry); + copy.Tags.Should().BeSameAs(original.Tags); + copy.Version.Should().Be(original.Version); + } + + [Fact] + public void SubOrchestrationOptions_CopyConstructor_CopiesAllProperties() + { + // Arrange + RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); + TaskRetryOptions retry = new(policy); Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; string instanceId = Guid.NewGuid().ToString(); TaskVersion version = new("1.0"); From 4d3febf66ab7a0b18e7456366acec542c9589315 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 27/52] docs: correct activity options summary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/TaskOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index 297bf43ee..419b67c16 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -82,7 +82,7 @@ public TaskOptions(TaskOptions options) } /// -/// Options that can be used to control the behavior of orchestrator task execution. This derived type can be used to +/// Options that can be used to control the behavior of activity task execution. This derived type can be used to /// supply extra options for activities. /// public record ActivityOptions : TaskOptions From f762183928c1b125352a5d39ef8a0915f468e338 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 28/52] feat: support explicit activity version overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Shims/TaskOrchestrationContextWrapper.cs | 19 ++- .../VersionedClassSyntaxIntegrationTests.cs | 31 ++++ .../VersionedClassSyntaxTestOrchestration.cs | 42 +++++ .../TaskOrchestrationContextWrapperTests.cs | 146 +++++++++++++++++- 4 files changed, 232 insertions(+), 6 deletions(-) diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index bfbf1e47f..68947c8c2 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -128,6 +128,18 @@ public override async Task CallActivityAsync( object? input = null, TaskOptions? options = null) { + static string GetRequestedActivityVersion(TaskOptions? taskOptions, string inheritedVersion) + { + if (taskOptions is ActivityOptions activityOptions + && activityOptions.Version is TaskVersion explicitVersion + && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + { + return explicitVersion.Version; + } + + 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. @@ -142,6 +154,7 @@ public override async Task CallActivityAsync( try { + string requestedVersion = GetRequestedActivityVersion(options, this.innerContext.Version); IDictionary tags = ImmutableDictionary.Empty; if (options is TaskOptions callActivityOptions) { @@ -157,7 +170,7 @@ public override async Task CallActivityAsync( { return await this.innerContext.ScheduleTask( name.Name, - this.innerContext.Version, + requestedVersion, options: ScheduleTaskOptions.CreateBuilder() .WithRetryOptions(policy.ToDurableTaskCoreRetryOptions()) .WithTags(tags) @@ -169,7 +182,7 @@ public override async Task CallActivityAsync( return await this.InvokeWithCustomRetryHandler( () => this.innerContext.ScheduleTask( name.Name, - this.innerContext.Version, + requestedVersion, options: ScheduleTaskOptions.CreateBuilder() .WithTags(tags) .Build(), @@ -182,7 +195,7 @@ public override async Task CallActivityAsync( { return await this.innerContext.ScheduleTask( name.Name, - this.innerContext.Version, + requestedVersion, options: ScheduleTaskOptions.CreateBuilder() .WithTags(tags) .Build(), diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs index c6465fd6b..606ccaa6f 100644 --- a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs @@ -50,6 +50,37 @@ public async Task ClassBasedVersionedOrchestrator_ExplicitVersionRoutesMatchingC 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 starting without a version fails when only versioned handlers are registered. /// diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs index defbcd4f0..382605687 100644 --- a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs @@ -32,6 +32,48 @@ public override Task RunAsync(TaskOrchestrationContext context, int inpu => Task.FromResult($"v2:{input}"); } + /// + /// Version 2 of the orchestration that explicitly targets an older activity version. + /// + [DurableTask("VersionedActivityOverrideOrchestration")] + [DurableTaskVersion("v2")] + public sealed class VersionedActivityOverrideOrchestrationV2 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => context.CallActivityAsync( + "VersionedActivityOverrideActivity", + input, + new ActivityOptions + { + Version = "v1", + }); + } + + /// + /// Version 1 of the explicitly-versioned activity. + /// + [DurableTask("VersionedActivityOverrideActivity")] + [DurableTaskVersion("v1")] + public sealed class VersionedActivityOverrideActivityV1 : TaskActivity + { + /// + public override Task RunAsync(TaskActivityContext context, int input) + => Task.FromResult($"activity-v1:{input}"); + } + + /// + /// Version 2 of the explicitly-versioned activity. + /// + [DurableTask("VersionedActivityOverrideActivity")] + [DurableTaskVersion("v2")] + public sealed class VersionedActivityOverrideActivityV2 : TaskActivity + { + /// + public override Task RunAsync(TaskActivityContext context, int input) + => Task.FromResult($"activity-v2:{input}"); + } + /// /// Version 1 of the continue-as-new orchestration. /// diff --git a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs index 06df43170..69e05738b 100644 --- a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs +++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs @@ -103,21 +103,140 @@ public void ContinueAsNew_WithOptionsNoVersion_CallsInnerContextWithoutVersion() innerContext.LastContinueAsNewVersion.Should().BeNull(); } + [Fact] + public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrchestrationVersion() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallActivityAsync( + "TestActivity", + 123, + new ActivityOptions + { + Version = "v1", + }); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v1"); + innerContext.LastScheduledTaskInput.Should().Be(123); + } + + [Fact] + public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrchestrationVersion_WithRetryPolicy() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallActivityAsync( + "TestActivity", + 123, + new ActivityOptions(new RetryPolicy(1, TimeSpan.FromSeconds(1))) + { + Version = "v1", + }); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v1"); + innerContext.LastScheduledTaskInput.Should().Be(123); + } + + [Fact] + public async Task CallActivityAsync_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_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); + } + + [Theory] + [InlineData(false, null)] + [InlineData(true, null)] + [InlineData(true, "")] + [InlineData(true, " ")] + public async Task CallActivityAsync_MissingOrEmptyActivityVersionUsesInheritedOrchestrationVersion( + bool setVersion, + string? explicitVersion) + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + ActivityOptions options = new(); + + if (setVersion) + { + options = options with + { + Version = explicitVersion is null ? default(TaskVersion?) : new TaskVersion(explicitVersion), + }; + } + + // Act + await wrapper.CallActivityAsync("TestActivity", 123, options); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v2"); + innerContext.LastScheduledTaskInput.Should().Be(123); + } + 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 override void ContinueAsNew(object input) { this.LastContinueAsNewInput = input; @@ -146,7 +265,28 @@ 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); + + Task CaptureScheduledTask(string name, string version, object[] parameters) + { + this.LastScheduledTaskName = name; + this.LastScheduledTaskVersion = version; + this.LastScheduledTaskInput = parameters.Length switch + { + 0 => null, + 1 => parameters[0], + _ => parameters, + }; + + return Task.FromResult(default(TResult)!); + } public override void SendEvent(OrchestrationInstance orchestrationInstance, string eventName, object eventData) => throw new NotImplementedException(); @@ -210,4 +350,4 @@ public override void SendEvent(OrchestrationInstance orchestrationInstance, stri throw new NotImplementedException(); } } -} \ No newline at end of file +} From 6c3fe3ba062750624904f5b95d01e6720b1b2d0f Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 29/52] feat: stamp generated activity helper versions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Generators/DurableTaskSourceGenerator.cs | 62 ++++- .../VersionedActivityTests.cs | 251 +++++++++++++++++- 2 files changed, 299 insertions(+), 14 deletions(-) diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 82d677d6c..e47f3d452 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -602,7 +602,11 @@ static void Execute( bool hasActivityTriggers = isMicrosoftDurableTask && activityTriggers.Count > 0; bool hasEvents = eventsInNamespace != null && eventsInNamespace.Count > 0; bool hasRegistration = isMicrosoftDurableTask && needsRegistrationMethod; - bool hasVersionedStandaloneHelpers = !isDurableFunctions && orchestratorsInNs.Any(task => !string.IsNullOrEmpty(task.TaskVersion)); + 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) @@ -639,15 +643,11 @@ public static class GeneratedDurableTaskExtensions AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperSuffix, applyGeneratedVersion); } - if (hasVersionedStandaloneHelpers) - { - AddStandaloneGeneratedVersionHelperMethods(sourceBuilder); - } - foreach (DurableTaskTypeInfo activity in activitiesInNs) { string helperSuffix = GetStandaloneTaskHelperSuffix(activity, isDurableFunctions, standaloneActivityCountsByTaskName); - AddActivityCallMethod(sourceBuilder, activity, targetNamespace, helperSuffix); + bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(activity.TaskVersion); + AddActivityCallMethod(sourceBuilder, activity, targetNamespace, helperSuffix, applyGeneratedVersion); if (isDurableFunctions) { @@ -655,6 +655,11 @@ public static class GeneratedDurableTaskExtensions } } + if (hasVersionedStandaloneHelpers) + { + AddStandaloneGeneratedVersionHelperMethods(sourceBuilder, hasVersionedStandaloneActivityHelpers); + } + foreach (DurableTaskTypeInfo entity in entitiesInNs) { if (isDurableFunctions) @@ -879,7 +884,7 @@ static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTas }}"); } - static void AddStandaloneGeneratedVersionHelperMethods(StringBuilder sourceBuilder) + static void AddStandaloneGeneratedVersionHelperMethods(StringBuilder sourceBuilder, bool includeActivityVersionHelpers) { sourceBuilder.AppendLine(@" static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) @@ -931,9 +936,43 @@ static void AddStandaloneGeneratedVersionHelperMethods(StringBuilder sourceBuild Version = version, }; }"); + + if (includeActivityVersionHelpers) + { + sourceBuilder.AppendLine().AppendLine(@" static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) + { + if (options is ActivityOptions activityOptions + && activityOptions.Version is TaskVersion explicitVersion + && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + { + return options; + } + + if (options is ActivityOptions existingActivityOptions) + { + return new ActivityOptions(existingActivityOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new ActivityOptions + { + Version = version, + }; + } + + return new ActivityOptions(options) + { + Version = version, + }; + }"); + } } - static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace, string helperSuffix) + static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace, string helperSuffix, bool applyGeneratedVersion) { string inputType = activity.GetInputTypeForNamespace(targetNamespace); string outputType = activity.GetOutputTypeForNamespace(targetNamespace); @@ -944,6 +983,9 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeIn } string simplifiedTypeName = SimplifyTypeName(activity.TypeName, targetNamespace); + string optionsExpression = applyGeneratedVersion + ? $"ApplyGeneratedActivityVersion(options, {ToCSharpStringLiteral(activity.TaskVersion)})" + : "options"; sourceBuilder.AppendLine($@" /// @@ -952,7 +994,7 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeIn /// public static Task<{outputType}> Call{activity.TaskName}{helperSuffix}Async(this TaskOrchestrationContext ctx, {inputParameter}, TaskOptions? options = null) {{ - return ctx.CallActivityAsync<{outputType}>(""{activity.TaskName}"", input, options); + return ctx.CallActivityAsync<{outputType}>(""{activity.TaskName}"", input, {optionsExpression}); }}"); } diff --git a/test/Generators.Tests/VersionedActivityTests.cs b/test/Generators.Tests/VersionedActivityTests.cs index cac806867..c7684ab20 100644 --- a/test/Generators.Tests/VersionedActivityTests.cs +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -38,7 +38,88 @@ class InvoiceActivity : TaskActivity /// public static Task CallInvoiceActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) { - return ctx.CallActivityAsync(""InvoiceActivity"", input, options); + return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v1"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) +{ + if (options is ActivityOptions activityOptions + && activityOptions.Version is TaskVersion explicitVersion + && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + { + return options; + } + + if (options is ActivityOptions existingActivityOptions) + { + return new ActivityOptions(existingActivityOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new ActivityOptions + { + Version = version, + }; + } + + return new ActivityOptions(options) + { + Version = version, + }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) @@ -84,7 +165,7 @@ class InvoiceActivityV2 : TaskActivity /// public static Task CallInvoiceActivity_v1Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) { - return ctx.CallActivityAsync(""InvoiceActivity"", input, options); + return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v1"")); } /// @@ -93,7 +174,88 @@ public static Task CallInvoiceActivity_v1Async(this TaskOrchestrationCon /// public static Task CallInvoiceActivity_v2Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) { - return ctx.CallActivityAsync(""InvoiceActivity"", input, options); + return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v2"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) +{ + if (options is ActivityOptions activityOptions + && activityOptions.Version is TaskVersion explicitVersion + && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + { + return options; + } + + if (options is ActivityOptions existingActivityOptions) + { + return new ActivityOptions(existingActivityOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new ActivityOptions + { + Version = version, + }; + } + + return new ActivityOptions(options) + { + Version = version, + }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) @@ -140,7 +302,88 @@ class InvoiceActivityV1Duplicate : TaskActivity /// public static Task CallInvoiceActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) { - return ctx.CallActivityAsync(""InvoiceActivity"", input, options); + return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v1"")); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + if (options?.Version is { Version: not null and not """" }) + { + return options; + } + + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + return new StartOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + { + return options; + } + + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return new SubOrchestrationOptions(subOrchestrationOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return new SubOrchestrationOptions(options) + { + Version = version, + }; +} + +static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) +{ + if (options is ActivityOptions activityOptions + && activityOptions.Version is TaskVersion explicitVersion + && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + { + return options; + } + + if (options is ActivityOptions existingActivityOptions) + { + return new ActivityOptions(existingActivityOptions) + { + Version = version, + }; + } + + if (options is null) + { + return new ActivityOptions + { + Version = version, + }; + } + + return new ActivityOptions(options) + { + Version = version, + }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) From bf28e2ed392dc8e5e35c8a04a10229b35e283bf5 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 30/52] test: cover activity retry handler override Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TaskOrchestrationContextWrapperTests.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs index 69e05738b..c71f012f8 100644 --- a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs +++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs @@ -149,6 +149,27 @@ await wrapper.CallActivityAsync( innerContext.LastScheduledTaskInput.Should().Be(123); } + [Fact] + public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrchestrationVersion_WithRetryHandler() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + ActivityOptions options = new(TaskOptions.FromRetryHandler(_ => false)) + { + Version = "v1", + }; + + // Act + await wrapper.CallActivityAsync("TestActivity", 123, options); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v1"); + innerContext.LastScheduledTaskInput.Should().Be(123); + } + [Fact] public async Task CallActivityAsync_PlainTaskOptionsUsesInheritedOrchestrationVersion() { From ac3229bdf58a3d3ceeb2651f8b5dea93790b366b Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 31/52] refactor: narrow generated activity helper emission Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Generators/DurableTaskSourceGenerator.cs | 18 ++- .../VersionedActivityTests.cs | 150 ------------------ 2 files changed, 14 insertions(+), 154 deletions(-) diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index e47f3d452..dc6a25f0e 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -657,7 +657,10 @@ public static class GeneratedDurableTaskExtensions if (hasVersionedStandaloneHelpers) { - AddStandaloneGeneratedVersionHelperMethods(sourceBuilder, hasVersionedStandaloneActivityHelpers); + AddStandaloneGeneratedVersionHelperMethods( + sourceBuilder, + hasVersionedStandaloneOrchestratorHelpers, + hasVersionedStandaloneActivityHelpers); } foreach (DurableTaskTypeInfo entity in entitiesInNs) @@ -884,9 +887,14 @@ static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTas }}"); } - static void AddStandaloneGeneratedVersionHelperMethods(StringBuilder sourceBuilder, bool includeActivityVersionHelpers) + static void AddStandaloneGeneratedVersionHelperMethods( + StringBuilder sourceBuilder, + bool includeOrchestrationVersionHelpers, + bool includeActivityVersionHelpers) { - sourceBuilder.AppendLine(@" + if (includeOrchestrationVersionHelpers) + { + sourceBuilder.AppendLine(@" static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { if (options?.Version is { Version: not null and not """" }) @@ -936,10 +944,12 @@ static void AddStandaloneGeneratedVersionHelperMethods(StringBuilder sourceBuild Version = version, }; }"); + } if (includeActivityVersionHelpers) { - sourceBuilder.AppendLine().AppendLine(@" static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) + sourceBuilder.AppendLine(@" + static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { if (options is ActivityOptions activityOptions && activityOptions.Version is TaskVersion explicitVersion diff --git a/test/Generators.Tests/VersionedActivityTests.cs b/test/Generators.Tests/VersionedActivityTests.cs index c7684ab20..a529284d0 100644 --- a/test/Generators.Tests/VersionedActivityTests.cs +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -41,56 +41,6 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v1"")); } -static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) -{ - if (options?.Version is { Version: not null and not """" }) - { - return options; - } - - if (options is null) - { - return new StartOrchestrationOptions - { - Version = version, - }; - } - - return new StartOrchestrationOptions(options) - { - Version = version, - }; -} - -static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) -{ - if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) - { - return options; - } - - if (options is SubOrchestrationOptions subOrchestrationOptions) - { - return new SubOrchestrationOptions(subOrchestrationOptions) - { - Version = version, - }; - } - - if (options is null) - { - return new SubOrchestrationOptions - { - Version = version, - }; - } - - return new SubOrchestrationOptions(options) - { - Version = version, - }; -} - static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { if (options is ActivityOptions activityOptions @@ -177,56 +127,6 @@ public static Task CallInvoiceActivity_v2Async(this TaskOrchestrationCon return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v2"")); } -static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) -{ - if (options?.Version is { Version: not null and not """" }) - { - return options; - } - - if (options is null) - { - return new StartOrchestrationOptions - { - Version = version, - }; - } - - return new StartOrchestrationOptions(options) - { - Version = version, - }; -} - -static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) -{ - if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) - { - return options; - } - - if (options is SubOrchestrationOptions subOrchestrationOptions) - { - return new SubOrchestrationOptions(subOrchestrationOptions) - { - Version = version, - }; - } - - if (options is null) - { - return new SubOrchestrationOptions - { - Version = version, - }; - } - - return new SubOrchestrationOptions(options) - { - Version = version, - }; -} - static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { if (options is ActivityOptions activityOptions @@ -305,56 +205,6 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v1"")); } -static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) -{ - if (options?.Version is { Version: not null and not """" }) - { - return options; - } - - if (options is null) - { - return new StartOrchestrationOptions - { - Version = version, - }; - } - - return new StartOrchestrationOptions(options) - { - Version = version, - }; -} - -static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) -{ - if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) - { - return options; - } - - if (options is SubOrchestrationOptions subOrchestrationOptions) - { - return new SubOrchestrationOptions(subOrchestrationOptions) - { - Version = version, - }; - } - - if (options is null) - { - return new SubOrchestrationOptions - { - Version = version, - }; - } - - return new SubOrchestrationOptions(options) - { - Version = version, - }; -} - static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { if (options is ActivityOptions activityOptions From 0720a16024f6809c61cb408ab3f7f3abd3f0c608 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 32/52] samples: add activity versioning sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.DurableTask.sln | 15 ++ README.md | 2 +- .../ActivityVersioningSample.csproj | 23 +++ samples/ActivityVersioningSample/Program.cs | 154 ++++++++++++++++++ samples/ActivityVersioningSample/README.md | 71 ++++++++ 5 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 samples/ActivityVersioningSample/ActivityVersioningSample.csproj create mode 100644 samples/ActivityVersioningSample/Program.cs create mode 100644 samples/ActivityVersioningSample/README.md diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 10a2b64bc..9c23a6d80 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -123,6 +123,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManage 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 @@ -733,6 +735,18 @@ Global {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 @@ -794,6 +808,7 @@ Global {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 ebea641de..0c2063026 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ The Durable Task Scheduler for Azure Functions is a managed backend that is curr 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) and [PerOrchestratorVersioningSample](samples/PerOrchestratorVersioningSample/README.md) (multi-version routing with `[DurableTaskVersion]`). +For runnable DTS emulator examples that demonstrate versioning, see the [WorkerVersioningSample](samples/WorkerVersioningSample/README.md) (deployment-based versioning), the [PerOrchestratorVersioningSample](samples/PerOrchestratorVersioningSample/README.md) (multi-version routing with `[DurableTaskVersion]`), and the [ActivityVersioningSample](samples/ActivityVersioningSample/README.md) (activity versioning with inherited defaults and explicit override support). ## Obtaining the Protobuf definitions diff --git a/samples/ActivityVersioningSample/ActivityVersioningSample.csproj b/samples/ActivityVersioningSample/ActivityVersioningSample.csproj new file mode 100644 index 000000000..e19bb7314 --- /dev/null +++ b/samples/ActivityVersioningSample/ActivityVersioningSample.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0;net10.0 + enable + + + + + + + + + + + + + + + diff --git a/samples/ActivityVersioningSample/Program.cs b/samples/ActivityVersioningSample/Program.cs new file mode 100644 index 000000000..ea80cd246 --- /dev/null +++ b/samples/ActivityVersioningSample/Program.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates activity versioning with [DurableTaskVersion]. +// Versioned orchestrators and versioned activities can share the same logical +// durable task names in one worker process. Plain activity calls inherit the +// orchestration instance version by default, while version-qualified helpers +// can explicitly override that routing when needed. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// Read the DTS connection string from configuration. +string connectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException( + "Set DURABLE_TASK_SCHEDULER_CONNECTION_STRING. " + + "For the local emulator: Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"); + +// AddAllGeneratedTasks() registers every [DurableTask]-annotated class in this +// project, including both versions of the orchestration and activity classes. +builder.Services.AddDurableTaskWorker(wb => +{ + wb.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + wb.UseDurableTaskScheduler(connectionString); +}); + +// Configure the client. Unlike worker-level versioning, the client does not +// stamp a single default version for every instance. +builder.Services.AddDurableTaskClient(cb => cb.UseDurableTaskScheduler(connectionString)); + +IHost host = builder.Build(); +await host.StartAsync(); + +await using DurableTaskClient client = host.Services.GetRequiredService(); + +Console.WriteLine("=== Activity versioning ([DurableTaskVersion]) ==="); +Console.WriteLine(); + +Console.WriteLine("Scheduling CheckoutWorkflow v1 ..."); +string v1Id = await client.ScheduleNewCheckoutWorkflow_1InstanceAsync(5); +OrchestrationMetadata v1 = await client.WaitForInstanceCompletionAsync(v1Id, getInputsAndOutputs: true); +Console.WriteLine($" Result: {v1.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Scheduling CheckoutWorkflow v2 ..."); +string v2Id = await client.ScheduleNewCheckoutWorkflow_2InstanceAsync(5); +OrchestrationMetadata v2 = await client.WaitForInstanceCompletionAsync(v2Id, getInputsAndOutputs: true); +Console.WriteLine($" Result: {v2.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Scheduling CheckoutWorkflow v2 with explicit ShippingQuote v1 override ..."); +string overrideId = await client.ScheduleNewOrchestrationInstanceAsync( + "ExplicitOverrideCheckoutWorkflow", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("2"), + }); +OrchestrationMetadata overrideResult = await client.WaitForInstanceCompletionAsync(overrideId, getInputsAndOutputs: true); +Console.WriteLine($" Result: {overrideResult.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Done! Both versions ran in the same worker process."); +Console.WriteLine("Default activity calls inherit the orchestration version, but versioned helpers can explicitly override it."); + +await host.StopAsync(); + +/// +/// CheckoutWorkflow v1 - default activity calls inherit orchestration version "1". +/// +[DurableTask("CheckoutWorkflow")] +[DurableTaskVersion("1")] +public sealed class CheckoutWorkflowV1 : TaskOrchestrator +{ + /// + public override async Task RunAsync(TaskOrchestrationContext context, int itemCount) + { + string quote = await context.CallActivityAsync("ShippingQuote", itemCount); + return $"Workflow v1 -> {quote}"; + } +} + +/// +/// CheckoutWorkflow v2 - default activity calls inherit orchestration version "2". +/// +[DurableTask("CheckoutWorkflow")] +[DurableTaskVersion("2")] +public sealed class CheckoutWorkflowV2 : TaskOrchestrator +{ + /// + public override async Task RunAsync(TaskOrchestrationContext context, int itemCount) + { + string quote = await context.CallActivityAsync("ShippingQuote", itemCount); + return $"Workflow v2 -> {quote}"; + } +} + +/// +/// CheckoutWorkflow v2 - explicitly overrides the inherited activity version. +/// +[DurableTask("ExplicitOverrideCheckoutWorkflow")] +[DurableTaskVersion("2")] +public sealed class ExplicitOverrideCheckoutWorkflowV2 : TaskOrchestrator +{ + /// + public override async Task RunAsync(TaskOrchestrationContext context, int itemCount) + { + string quote = await context.CallShippingQuote_1Async(itemCount); + return $"Workflow v2 explicit override -> {quote}"; + } +} + +/// +/// ShippingQuote v1 - uses a flat shipping charge. +/// +[DurableTask("ShippingQuote")] +[DurableTaskVersion("1")] +public sealed class ShippingQuoteV1 : TaskActivity +{ + /// + public override Task RunAsync(TaskActivityContext context, int itemCount) + { + int total = (itemCount * 10) + 7; + return Task.FromResult($"activity v1 quote: ${total} (flat $7 shipping)"); + } +} + +/// +/// ShippingQuote v2 - applies a bulk discount and cheaper shipping. +/// +[DurableTask("ShippingQuote")] +[DurableTaskVersion("2")] +public sealed class ShippingQuoteV2 : TaskActivity +{ + /// + public override Task RunAsync(TaskActivityContext context, int itemCount) + { + int total = (itemCount * 10) + 5; + if (itemCount >= 5) + { + total -= 10; + } + + return Task.FromResult($"activity v2 quote: ${total} ($10 bulk discount + $5 shipping)"); + } +} diff --git a/samples/ActivityVersioningSample/README.md b/samples/ActivityVersioningSample/README.md new file mode 100644 index 000000000..13c459da3 --- /dev/null +++ b/samples/ActivityVersioningSample/README.md @@ -0,0 +1,71 @@ +# Activity Versioning Sample + +This sample demonstrates activity versioning with `[DurableTaskVersion]`, where multiple implementations of the same logical activity name coexist in one worker process and can be selected either by the orchestration instance version or by an explicit version-qualified helper. + +## What it shows + +- Two classes share the same `[DurableTask("ShippingQuote")]` name but have different `[DurableTaskVersion]` values +- Two versions of `CheckoutWorkflow` call the same logical activity name in one worker process using the default inherited-routing behavior +- The orchestration instance version is still the default for activity scheduling, so `CheckoutWorkflow` v1 routes to `ShippingQuote` v1 and `CheckoutWorkflow` v2 routes to `ShippingQuote` v2 +- Version-qualified activity helpers like `CallShippingQuote_1Async()` and `CallShippingQuote_2Async()` now explicitly select those versions when called from an orchestration +- A third orchestration demonstrates explicitly overriding a `v2` orchestration to call the `ShippingQuote` v1 helper +- `AddAllGeneratedTasks()` registers both orchestration and activity versions automatically + +## Prerequisites + +- .NET 8.0 or 10.0 SDK +- [Docker](https://www.docker.com/get-started) + +## Running the Sample + +### 1. Start the DTS emulator + +```bash +docker run --name durabletask-emulator -d -p 8080:8080 -e ASPNETCORE_URLS=http://+:8080 mcr.microsoft.com/dts/dts-emulator:latest +``` + +### 2. Set the connection string + +```bash +export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" +``` + +### 3. Run the sample + +```bash +dotnet run +``` + +Expected output: + +```text +=== Activity versioning ([DurableTaskVersion]) === + +Scheduling CheckoutWorkflow v1 ... + Result: Workflow v1 -> activity v1 quote: $57 (flat $7 shipping) + +Scheduling CheckoutWorkflow v2 ... + Result: Workflow v2 -> activity v2 quote: $45 ($10 bulk discount + $5 shipping) + +Scheduling CheckoutWorkflow v2 with explicit ShippingQuote v1 override ... + Result: Workflow v2 explicit override -> activity v1 quote: $57 (flat $7 shipping) + +Done! Both versions ran in the same worker process. +Default activity calls inherit the orchestration version, but versioned helpers can explicitly override it. +``` + +### 4. Clean up + +```bash +docker rm -f durabletask-emulator +``` + +## When to use this approach + +Activity versioning is useful when: + +- You need orchestration and activity behavior to evolve together across versions +- You want multiple versions of the same logical activity active simultaneously in one worker +- You want activity routing to follow the orchestration instance version by default, with explicit opt-in overrides when needed + +For deployment-based versioning, see the [WorkerVersioningSample](../WorkerVersioningSample/README.md). For the orchestration-focused version of this pattern, see the [PerOrchestratorVersioningSample](../PerOrchestratorVersioningSample/README.md). From 543732db037b2be6abdedca15ddaf93e94792a12 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 33/52] docs: update README for Durable Task Scheduler usage and clarify documentation links --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0c2063026..6a63b3dfa 100644 --- a/README.md +++ b/README.md @@ -192,11 +192,11 @@ Use `ContinueAsNewOptions.NewVersion` to migrate long-running orchestrations at 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 -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). +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. -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). +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). 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). From 2381e5b0cfe87de12eba9d75717f7f7b58dc8bcb Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:17:23 -0700 Subject: [PATCH 34/52] fix: finalize versioning review follow-ups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plans/2026-04-01-dts-versioning-sample.md | 418 ------------------ ...2026-04-01-dts-versioning-sample-design.md | 206 --------- src/Grpc/orchestrator_service.proto | 1 + .../Dispatcher/TaskOrchestrationDispatcher.cs | 5 + .../Sidecar/Grpc/ProtobufUtils.cs | 7 + .../Sidecar/Grpc/TaskHubGrpcServer.cs | 11 +- src/Worker/Core/ActivityVersioning.cs | 15 + src/Worker/Core/DurableTaskFactory.cs | 6 +- .../Core/DurableTaskWorkerWorkItemFilters.cs | 38 +- src/Worker/Core/IVersionedActivityFactory.cs | 7 +- .../Shims/TaskOrchestrationContextWrapper.cs | 43 +- .../Grpc/GrpcDurableTaskWorker.Processor.cs | 35 +- .../VersionedClassSyntaxIntegrationTests.cs | 127 ++++++ .../VersionedClassSyntaxTestOrchestration.cs | 85 ++++ .../UseWorkItemFiltersTests.cs | 50 ++- ...rableTaskFactoryActivityVersioningTests.cs | 25 ++ .../TaskOrchestrationContextWrapperTests.cs | 49 +- 17 files changed, 461 insertions(+), 667 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-01-dts-versioning-sample.md delete mode 100644 docs/superpowers/specs/2026-04-01-dts-versioning-sample-design.md create mode 100644 src/Worker/Core/ActivityVersioning.cs diff --git a/docs/superpowers/plans/2026-04-01-dts-versioning-sample.md b/docs/superpowers/plans/2026-04-01-dts-versioning-sample.md deleted file mode 100644 index c8eacb854..000000000 --- a/docs/superpowers/plans/2026-04-01-dts-versioning-sample.md +++ /dev/null @@ -1,418 +0,0 @@ -# DTS Versioning Sample Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a new DTS emulator console sample that demonstrates worker-level versioning and per-orchestrator `[DurableTaskVersion]` routing in one runnable app. - -**Architecture:** Create a single `samples/VersioningSample` console app with two sequential demos. The first demo uses manual orchestration registration plus `UseVersioning(...)` to show worker-scoped versioning; the second demo uses class-based orchestrators plus `[DurableTaskVersion]` and generated registration to show same-name multi-version routing and `ContinueAsNewOptions.NewVersion` migration. - -**Tech Stack:** .NET console app, `HostApplicationBuilder`, `Microsoft.DurableTask.Client.AzureManaged`, `Microsoft.DurableTask.Worker.AzureManaged`, `Microsoft.DurableTask.Generators`, DTS emulator via `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` - ---- - -### File map - -- Create: `samples/VersioningSample/VersioningSample.csproj` — sample project definition -- Create: `samples/VersioningSample/Program.cs` — both demos, helper methods, and sample task types -- Create: `samples/VersioningSample/README.md` — emulator setup, run instructions, and explanation of both approaches -- Modify: `Microsoft.DurableTask.sln` — include the new sample project -- Modify: `README.md` — add a short reference to the new DTS sample in the Durable Task Scheduler section - -### Task 1: Scaffold the sample project and implement the worker-level versioning demo - -**Files:** -- Create: `samples/VersioningSample/VersioningSample.csproj` -- Create: `samples/VersioningSample/Program.cs` - -- [ ] **Step 1: Write the failing sample shell** - -```xml - - - - - Exe - net8.0;net10.0 - enable - - - - - - - - - - - - - - -``` - -```csharp -// samples/VersioningSample/Program.cs -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Extensions.Hosting; - -HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); - -await RunWorkerLevelVersioningDemoAsync(builder); -``` - -- [ ] **Step 2: Run build to verify it fails** - -Run: `dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal` -Expected: FAIL with a compile error for `RunWorkerLevelVersioningDemoAsync` - -- [ ] **Step 3: Write the minimal worker-level demo implementation** - -```csharp -// samples/VersioningSample/Program.cs -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// This sample demonstrates the two versioning models supported by durabletask-dotnet -// when connected directly to the Durable Task Scheduler (DTS) emulator. - -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); - -string schedulerConnectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") - ?? throw new InvalidOperationException("DURABLE_TASK_SCHEDULER_CONNECTION_STRING is not set."); - -await RunWorkerLevelVersioningDemoAsync(schedulerConnectionString); - -static async Task RunWorkerLevelVersioningDemoAsync(string schedulerConnectionString) -{ - Console.WriteLine("=== Worker-level versioning ==="); - - string v1Result = await RunWorkerScopedVersionAsync( - schedulerConnectionString, - workerVersion: "1.0", - outputPrefix: "worker-v1"); - Console.WriteLine($"Worker version 1.0 completed with output: {v1Result}"); - - string v2Result = await RunWorkerScopedVersionAsync( - schedulerConnectionString, - workerVersion: "2.0", - outputPrefix: "worker-v2"); - Console.WriteLine($"Worker version 2.0 completed with output: {v2Result}"); - - Console.WriteLine("Worker-level versioning keeps one implementation active per worker run."); -} - -static async Task RunWorkerScopedVersionAsync( - string schedulerConnectionString, - string workerVersion, - string outputPrefix) -{ - HostApplicationBuilder builder = Host.CreateApplicationBuilder(); - builder.Services.AddDurableTaskClient(clientBuilder => - { - clientBuilder.UseDurableTaskScheduler(schedulerConnectionString); - clientBuilder.UseDefaultVersion(workerVersion); - }); - - builder.Services.AddDurableTaskWorker(workerBuilder => - { - workerBuilder.AddTasks(tasks => - { - tasks.AddOrchestratorFunc("WorkerLevelGreeting", (context, input) => - Task.FromResult($"{outputPrefix}:{context.Version}:{input}")); - }); - workerBuilder.UseDurableTaskScheduler(schedulerConnectionString); - workerBuilder.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions - { - Version = workerVersion, - DefaultVersion = workerVersion, - MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, - FailureStrategy = DurableTaskWorkerOptions.VersionFailureStrategy.Fail, - }); - }); - - IHost host = builder.Build(); - await host.StartAsync(); - - DurableTaskClient client = host.Services.GetRequiredService(); - string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( - "WorkerLevelGreeting", - input: "hello", - new StartOrchestrationOptions { Version = workerVersion }); - OrchestrationMetadata metadata = await client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true); - string output = metadata.ReadOutputAs()!; - - await host.StopAsync(); - return output; -} -``` - -- [ ] **Step 4: Run build to verify the worker-level demo compiles** - -Run: `dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add samples/VersioningSample/VersioningSample.csproj samples/VersioningSample/Program.cs -git commit -m "feat: add DTS worker versioning sample skeleton" -``` - -### Task 2: Add the per-orchestrator versioning demo to the same sample - -**Files:** -- Modify: `samples/VersioningSample/Program.cs` - -- [ ] **Step 1: Write the failing per-orchestrator demo calls** - -```csharp -// Insert below the worker-level demo call in Program.cs -await RunPerOrchestratorVersioningDemoAsync(schedulerConnectionString); - -// Insert below RunWorkerLevelVersioningDemoAsync -static async Task RunPerOrchestratorVersioningDemoAsync(string schedulerConnectionString) -{ - using IHost host = BuildPerOrchestratorHost(schedulerConnectionString); - await host.StartAsync(); - - DurableTaskClient client = host.Services.GetRequiredService(); - string v1InstanceId = await client.ScheduleNewOrderWorkflow_v1InstanceAsync(5); - string v2InstanceId = await client.ScheduleNewOrderWorkflow_v2InstanceAsync(5); - string migrationInstanceId = await client.ScheduleNewMigratingOrderWorkflow_v1InstanceAsync(4); -} -``` - -- [ ] **Step 2: Run build to verify it fails** - -Run: `dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal` -Expected: FAIL because `BuildPerOrchestratorHost`, `OrderWorkflow` types, and generated helper methods do not exist yet - -- [ ] **Step 3: Write the per-orchestrator versioning implementation** - -```csharp -// Add to samples/VersioningSample/Program.cs -using Microsoft.DurableTask.Client; - -await RunPerOrchestratorVersioningDemoAsync(schedulerConnectionString); - -static async Task RunPerOrchestratorVersioningDemoAsync(string schedulerConnectionString) -{ - Console.WriteLine("=== Per-orchestrator versioning ==="); - - using IHost host = BuildPerOrchestratorHost(schedulerConnectionString); - await host.StartAsync(); - - DurableTaskClient client = host.Services.GetRequiredService(); - - string v1InstanceId = await client.ScheduleNewOrderWorkflow_v1InstanceAsync(5); - OrchestrationMetadata v1 = await client.WaitForInstanceCompletionAsync(v1InstanceId, getInputsAndOutputs: true); - Console.WriteLine($"OrderWorkflow v1 output: {v1.ReadOutputAs()}"); - - string v2InstanceId = await client.ScheduleNewOrderWorkflow_v2InstanceAsync(5); - OrchestrationMetadata v2 = await client.WaitForInstanceCompletionAsync(v2InstanceId, getInputsAndOutputs: true); - Console.WriteLine($"OrderWorkflow v2 output: {v2.ReadOutputAs()}"); - - string migrationInstanceId = await client.ScheduleNewMigratingOrderWorkflow_v1InstanceAsync(4); - OrchestrationMetadata migration = await client.WaitForInstanceCompletionAsync(migrationInstanceId, getInputsAndOutputs: true); - Console.WriteLine($"Migrating workflow output: {migration.ReadOutputAs()}"); - - await host.StopAsync(); -} - -static IHost BuildPerOrchestratorHost(string schedulerConnectionString) -{ - HostApplicationBuilder builder = Host.CreateApplicationBuilder(); - builder.Services.AddDurableTaskClient(clientBuilder => clientBuilder.UseDurableTaskScheduler(schedulerConnectionString)); - builder.Services.AddDurableTaskWorker(workerBuilder => - { - workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); - workerBuilder.UseDurableTaskScheduler(schedulerConnectionString); - }); - - return builder.Build(); -} - -[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}"); -} - -[DurableTask("MigratingOrderWorkflow")] -[DurableTaskVersion("v1")] -public sealed class MigratingOrderWorkflowV1 : TaskOrchestrator -{ - public override Task RunAsync(TaskOrchestrationContext context, int input) - { - context.ContinueAsNew(new ContinueAsNewOptions - { - NewInput = input + 1, - NewVersion = "v2", - }); - - return Task.FromResult(string.Empty); - } -} - -[DurableTask("MigratingOrderWorkflow")] -[DurableTaskVersion("v2")] -public sealed class MigratingOrderWorkflowV2 : TaskOrchestrator -{ - public override Task RunAsync(TaskOrchestrationContext context, int input) - => Task.FromResult($"v2:{input}"); -} -``` - -- [ ] **Step 4: Run build to verify the full sample compiles** - -Run: `dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal` -Expected: PASS - -- [ ] **Step 5: Run against the DTS emulator** - -Run: `dotnet run --project samples/VersioningSample/VersioningSample.csproj` -Expected: output includes: -- `Worker version 1.0 completed with output: worker-v1:1.0:hello` -- `Worker version 2.0 completed with output: worker-v2:2.0:hello` -- `OrderWorkflow v1 output: v1:5` -- `OrderWorkflow v2 output: v2:5` -- `Migrating workflow output: v2:5` - -- [ ] **Step 6: Commit** - -```bash -git add samples/VersioningSample/Program.cs -git commit -m "feat: demonstrate per-orchestrator DTS versioning" -``` - -### Task 3: Document the sample and wire it into the repo - -**Files:** -- Create: `samples/VersioningSample/README.md` -- Modify: `Microsoft.DurableTask.sln` -- Modify: `README.md` - -- [ ] **Step 1: Write the sample README** - -````md -# DTS Versioning Sample - -This sample demonstrates the two versioning models available when you run durabletask-dotnet directly against the Durable Task Scheduler (DTS) emulator: - -1. **Worker-level versioning** via `UseVersioning(...)` -2. **Per-orchestrator versioning** via `[DurableTaskVersion]` - -## Run 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 -``` - -## Configure the connection string - -```bash -export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" -``` - -## Run the sample - -```bash -dotnet run --project samples/VersioningSample/VersioningSample.csproj -``` - -## What to look for - -- The worker-level demo runs one implementation per worker version (`1.0`, then `2.0`) -- The per-orchestrator demo keeps `v1` and `v2` of the same logical orchestration active in one worker process -- The migration demo uses `ContinueAsNewOptions.NewVersion` to move from `v1` to `v2` - -> Do not combine `[DurableTaskVersion]` routing with worker-level `UseVersioning(...)` in the same worker path. Both features use the orchestration instance version field. -```` - -- [ ] **Step 2: Add the sample to the solution** - -Run: - -```bash -dotnet sln Microsoft.DurableTask.sln add samples/VersioningSample/VersioningSample.csproj -``` - -Expected: `Project 'samples/VersioningSample/VersioningSample.csproj' added to the solution.` - -- [ ] **Step 3: Add a short root README reference** - -```md - - -For a runnable DTS emulator example that compares worker-level versioning with per-orchestrator `[DurableTaskVersion]` routing, see [samples/VersioningSample](samples/VersioningSample/README.md). -``` - -- [ ] **Step 4: Run final sample build verification** - -Run: `dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add samples/VersioningSample/README.md Microsoft.DurableTask.sln README.md -git commit -m "docs: add DTS versioning sample" -``` - -### Task 4: Final verification - -**Files:** -- Verify only; no new files - -- [ ] **Step 1: Run the focused sample verification** - -Run: - -```bash -dotnet build samples/VersioningSample/VersioningSample.csproj --nologo --verbosity minimal && \ -dotnet run --project samples/VersioningSample/VersioningSample.csproj -``` - -Expected: -- build succeeds -- console output shows both worker-level and per-orchestrator demo results - -- [ ] **Step 2: Run impacted versioning coverage** - -Run: - -```bash -dotnet test test/Worker/Core.Tests/Worker.Tests.csproj --filter "DurableTaskFactoryVersioningTests|UseWorkItemFiltersTests" --nologo --verbosity minimal && \ -dotnet test test/Generators.Tests/Generators.Tests.csproj --filter "VersionedOrchestratorTests|AzureFunctionsTests" --nologo --verbosity minimal && \ -dotnet test test/Grpc.IntegrationTests/Grpc.IntegrationTests.csproj --filter "VersionedClassSyntaxIntegrationTests|OrchestrationVersionPassedThroughContext|OrchestrationVersioning_MatchTypeNotSpecified_NoVersionFailure|OrchestrationVersioning_MatchTypeNone_NoVersionFailure|OrchestrationVersioning_MatchTypeCurrentOrOlder_VersionSuccess|SubOrchestrationInheritsDefaultVersion|OrchestrationTaskVersionOverridesDefaultVersion|SubOrchestrationTaskVersionOverridesDefaultVersion|ContinueAsNewWithNewVersion" --nologo --verbosity minimal -``` - -Expected: PASS across all targeted worker, generator, and gRPC integration tests - -- [ ] **Step 3: Commit any verification-only adjustments** - -```bash -git add -A -git commit -m "chore: finalize DTS versioning sample" -``` diff --git a/docs/superpowers/specs/2026-04-01-dts-versioning-sample-design.md b/docs/superpowers/specs/2026-04-01-dts-versioning-sample-design.md deleted file mode 100644 index 16d520e32..000000000 --- a/docs/superpowers/specs/2026-04-01-dts-versioning-sample-design.md +++ /dev/null @@ -1,206 +0,0 @@ -## DTS versioning sample design - -### Goal - -Add a new sample app under `samples/` that runs against the Durable Task Scheduler (DTS) emulator and demonstrates both versioning approaches supported by this repo: - -1. **Worker-level versioning** via `UseVersioning(...)` -2. **Per-orchestrator versioning** via `[DurableTaskVersion]` - -### Assumption - -The user did not answer the clarification prompt, so this design assumes “both versioning approaches” means the two approaches above. This matches the current repo capabilities and the recently implemented per-orchestration versioning work. - -## Approaches considered - -### Approach 1 — One console sample with two sequential demos (**recommended**) - -Create a single console app that: - -1. runs a **worker-level versioning** demo first -2. then runs a **per-orchestrator versioning** demo - -Each demo starts its own worker/client host against the same DTS emulator connection string and prints the results to the console. - -**Pros** -- Matches the request for a single sample app -- Makes the comparison between the two approaches explicit -- Keeps DTS emulator setup and README instructions simple -- Fits the repo’s existing console-sample patterns - -**Cons** -- The sample has more code than a single-focus sample -- The worker-level demo and per-orchestrator demo must be kept visually separated to avoid confusion - -### Approach 2 — One console sample with command-line modes - -Create one sample app with subcommands such as `worker-level` and `per-orchestrator`. - -**Pros** -- Strong separation of concerns -- Easier to explain each path independently - -**Cons** -- More ceremony for a sample that should be easy to run -- Users must rerun or pass arguments to see the full story - -### Approach 3 — Two separate sample apps - -Create one DTS sample for worker-level versioning and another for per-orchestrator versioning. - -**Pros** -- Simplest code per sample -- Each sample stays narrowly focused - -**Cons** -- Conflicts with the “sample app” request -- Duplicates emulator setup, host wiring, and documentation -- Makes it harder to compare the two approaches side-by-side - -## Decision - -Use **Approach 1**: one DTS emulator console sample with two sequential demos. - -This is the clearest way to teach the contrast: - -- **worker-level versioning** is for rolling a single logical implementation per worker run -- **per-orchestrator versioning** is for keeping multiple orchestrator implementations for the same logical name active in one worker process - -## Sample structure - -### New sample - -- `samples/VersioningSample/VersioningSample.csproj` -- `samples/VersioningSample/Program.cs` -- `samples/VersioningSample/README.md` - -### Existing files to update - -- `Microsoft.DurableTask.sln` — add the new sample project -- `README.md` — add a short DTS sample reference in the Durable Task Scheduler section - -## Programming model - -### Shared sample shape - -The sample will: - -- use `HostApplicationBuilder` -- read `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` from configuration -- configure both `AddDurableTaskClient(...UseDurableTaskScheduler(...))` and `AddDurableTaskWorker(...UseDurableTaskScheduler(...))` -- start the host, run the demos, print results, and stop the host - -The sample will target `net8.0;net10.0`, matching the newer DTS console sample pattern. - -### Demo 1 — Worker-level versioning - -This demo will show that worker-level versioning is **host-scoped**, not multi-version-in-one-process. - -Design: - -1. Start a host configured with: - - an unversioned orchestration registration for a logical name such as `WorkerLevelGreeting` - - `UseVersioning(new DurableTaskWorkerOptions.VersioningOptions { Version = "1.0", MatchStrategy = Strict, FailureStrategy = Fail, DefaultVersion = "1.0" })` -2. Schedule and complete an instance using version `1.0` -3. Stop the host -4. Start a second host with the same logical orchestration name but a different implementation and worker version `2.0` -5. Schedule and complete an instance using version `2.0` - -The sample output should make the lesson explicit: **worker-level versioning upgrades the worker deployment; it does not keep multiple implementations of the same orchestration active in one worker process**. - -Implementation note: - -- To avoid class-name and source-generator collisions, this demo should use explicit manual registrations (`AddOrchestratorFunc(...)` or equivalent) rather than multiple same-name unversioned `[DurableTask]` classes in the same project. - -### Demo 2 — Per-orchestrator versioning - -This demo will show that `[DurableTaskVersion]` allows multiple implementations of the same logical orchestration name to coexist in one worker process. - -Design: - -1. Define two class-based orchestrators with the same `[DurableTask("OrderWorkflow")]` name and distinct `[DurableTaskVersion("v1")]` / `[DurableTaskVersion("v2")]` values -2. Register them together using generated `AddAllGeneratedTasks()` -3. Start one instance with version `v1` -4. Start another instance with version `v2` -5. Run a small migration example that starts on `v1` and calls `ContinueAsNew(new ContinueAsNewOptions { NewVersion = "v2", ... })` - -The sample output should show: - -- `v1` routed to the `v1` implementation -- `v2` routed to the `v2` implementation -- `ContinueAsNewOptions.NewVersion` migrating a long-running orchestration at a replay-safe boundary - -Implementation note: - -- This demo should use class-based syntax and the source generator because `[DurableTaskVersion]` is part of the new feature being taught. - -## Code organization - -To align with the repo’s sample guidance, the sample should stay compact and readable: - -- one `Program.cs` -- top-of-file comments explaining the two demos -- helper methods such as `RunWorkerLevelVersioningDemoAsync(...)` and `RunPerOrchestratorVersioningDemoAsync(...)` -- task and activity classes placed at the bottom of the file - -## README content - -The sample README should include: - -1. What the sample demonstrates -2. The distinction between worker-level and per-orchestrator versioning -3. DTS emulator startup instructions: - - ```bash - docker run --name durabletask-emulator -d -p 8080:8080 -e ASPNETCORE_URLS=http://+:8080 mcr.microsoft.com/dts/dts-emulator:latest - ``` - -4. Connection string setup: - - ```bash - export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" - ``` - -5. Run instructions: - - ```bash - dotnet run --project samples/VersioningSample/VersioningSample.csproj - ``` - -6. A short explanation of when to choose each versioning approach -7. A note that per-orchestrator `[DurableTaskVersion]` routing should not be combined with worker-level `UseVersioning(...)` in the same worker path because both use the orchestration instance version field - -## Error handling and UX - -The sample should fail fast when `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` is missing. - -Console output should clearly label: - -- when the sample is running the worker-level demo -- when it is running the per-orchestrator demo -- which version completed -- why the two approaches are different - -## Verification - -Implementation verification should include: - -1. `dotnet build samples/VersioningSample/VersioningSample.csproj` -2. `dotnet run --project samples/VersioningSample/VersioningSample.csproj` against a running DTS emulator -3. confirmation that the sample prints successful results for: - - worker-level `1.0` - - worker-level `2.0` - - per-orchestrator `v1` - - per-orchestrator `v2` - - per-orchestrator migration `v1 -> v2` - -## Scope boundaries - -This sample will **not**: - -- attempt to demonstrate Azure Functions multi-version routing -- add automated sample tests -- demonstrate every version-match strategy -- mix worker-level versioning and per-orchestrator versioning inside the same running worker path - -The sample is educational, not exhaustive. diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto index 0c34d986d..77068612b 100644 --- a/src/Grpc/orchestrator_service.proto +++ b/src/Grpc/orchestrator_service.proto @@ -25,6 +25,7 @@ message ActivityRequest { OrchestrationInstance orchestrationInstance = 4; int32 taskId = 5; TraceContext parentTraceContext = 6; + map tags = 7; } message ActivityResponse { diff --git a/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs b/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs index 3dfe627b4..bea00fd47 100644 --- a/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs +++ b/src/InProcessTestHost/Sidecar/Dispatcher/TaskOrchestrationDispatcher.cs @@ -284,6 +284,11 @@ void ApplyOrchestratorActions( scheduleTaskAction.Version, scheduleTaskAction.Input); + if (scheduleTaskAction.Tags is not null) + { + scheduledEvent.Tags = new Dictionary(scheduleTaskAction.Tags, StringComparer.Ordinal); + } + if (action is GrpcScheduleTaskOrchestratorAction { ParentTraceContext: not null } grpcAction) { scheduledEvent.ParentTraceContext ??= new(grpcAction.ParentTraceContext.TraceParent, grpcAction.ParentTraceContext.TraceState); diff --git a/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs b/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs index 8289574b6..eeae533cc 100644 --- a/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs +++ b/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs @@ -133,6 +133,12 @@ public static Proto.HistoryEvent ToHistoryEventProto(HistoryEvent e) TraceState = taskScheduledEvent.ParentTraceContext.TraceState, }, }; + + if (taskScheduledEvent.Tags is not null) + { + payload.TaskScheduled.Tags.Add(taskScheduledEvent.Tags); + } + break; case EventType.TaskCompleted: var taskCompletedEvent = (TaskCompletedEvent)e; @@ -273,6 +279,7 @@ public static OrchestratorAction ToOrchestratorAction(Proto.OrchestratorAction a Id = a.Id, Input = a.ScheduleTask.Input, Name = a.ScheduleTask.Name, + Tags = a.ScheduleTask.Tags, Version = a.ScheduleTask.Version, ParentTraceContext = a.ScheduleTask.ParentTraceContext is not null ? new DistributedTraceContext(a.ScheduleTask.ParentTraceContext.TraceParent, a.ScheduleTask.ParentTraceContext.TraceState) diff --git a/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs b/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs index 50f1fe155..016baef4e 100644 --- a/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs +++ b/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs @@ -855,7 +855,7 @@ async Task ITaskExecutor.ExecuteActivity(OrchestrationI try { - await this.SendWorkItemToClientAsync(new P.WorkItem + P.WorkItem workItem = new() { ActivityRequest = new P.ActivityRequest { @@ -876,7 +876,14 @@ await this.SendWorkItemToClientAsync(new P.WorkItem } : null, }, - }); + }; + + if (activityEvent.Tags is not null) + { + workItem.ActivityRequest.Tags.Add(activityEvent.Tags); + } + + await this.SendWorkItemToClientAsync(workItem); } catch { diff --git a/src/Worker/Core/ActivityVersioning.cs b/src/Worker/Core/ActivityVersioning.cs new file mode 100644 index 000000000..354bb8cdb --- /dev/null +++ b/src/Worker/Core/ActivityVersioning.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker; + +/// +/// Internal helpers for preserving activity version-selection semantics across worker dispatch. +/// +static class ActivityVersioning +{ + /// + /// Internal tag stamped on scheduled activity events when the caller explicitly chooses an activity version. + /// + internal const string ExplicitVersionTagName = "microsoft.durabletask.activity.explicit-version"; +} diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index b5374e097..8216f9752 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -36,6 +36,7 @@ public bool TryCreateActivity( TaskName name, TaskVersion version, IServiceProvider serviceProvider, + bool allowVersionFallback, [NotNullWhen(true)] out ITaskActivity? activity) { Check.NotNull(serviceProvider); @@ -46,7 +47,8 @@ public bool TryCreateActivity( return true; } - if (!string.IsNullOrWhiteSpace(version.Version) + if (allowVersionFallback + && !string.IsNullOrWhiteSpace(version.Version) && this.activities.TryGetValue(new ActivityVersionKey(name, default(TaskVersion)), out factory)) { activity = factory.Invoke(serviceProvider); @@ -60,7 +62,7 @@ public bool TryCreateActivity( /// public bool TryCreateActivity( TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskActivity? activity) - => this.TryCreateActivity(name, default(TaskVersion), serviceProvider, out activity); + => this.TryCreateActivity(name, default(TaskVersion), serviceProvider, allowVersionFallback: false, out activity); /// public bool TryCreateOrchestrator( diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index 9015926a9..1caa9882a 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -35,6 +35,12 @@ public class DurableTaskWorkerWorkItemFilters /// A new instance of constructed from the provided registry. internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(DurableTaskRegistry registry, DurableTaskWorkerOptions? workerOptions) { + IReadOnlyList? strictWorkerVersions = + workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict + && !string.IsNullOrWhiteSpace(workerOptions.Versioning.Version) + ? [workerOptions.Versioning.Version] + : null; + // 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. @@ -42,13 +48,7 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable .GroupBy(orchestration => orchestration.Key.Name, StringComparer.OrdinalIgnoreCase) .Select(group => { - bool hasUnversionedRegistration = group.Any(entry => string.IsNullOrWhiteSpace(entry.Key.Version)); - IReadOnlyList versions = hasUnversionedRegistration - ? [] - : group.Select(entry => entry.Key.Version) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) - .ToArray(); + IReadOnlyList versions = strictWorkerVersions ?? GetRegistrationVersions(group.Select(entry => entry.Key.Version)); return new OrchestrationFilter { @@ -62,16 +62,7 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable .GroupBy(activity => activity.Key.Name, StringComparer.OrdinalIgnoreCase) .Select(group => { - // Activity filters mirror orchestration filters: any unversioned registration becomes a catch-all - // for that logical name, while fully versioned groups advertise only the explicit versions. - bool hasUnversionedRegistration = group.Any(entry => string.IsNullOrWhiteSpace(entry.Key.Version)); - IReadOnlyList versions = hasUnversionedRegistration - ? [] - : group.Select(entry => entry.Key.Version) - .Where(version => !string.IsNullOrWhiteSpace(version)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) - .ToArray(); + IReadOnlyList versions = strictWorkerVersions ?? GetRegistrationVersions(group.Select(entry => entry.Key.Version)); return new ActivityFilter { @@ -91,6 +82,19 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable Name = entity.Key.ToString(), }).ToList(), }; + + static IReadOnlyList GetRegistrationVersions(IEnumerable versions) + { + bool hasUnversionedRegistration = versions.Any(string.IsNullOrWhiteSpace); + return hasUnversionedRegistration + ? [] + : versions + .Where(version => !string.IsNullOrWhiteSpace(version)) + .Cast() + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } } /// diff --git a/src/Worker/Core/IVersionedActivityFactory.cs b/src/Worker/Core/IVersionedActivityFactory.cs index 244835346..45f31109b 100644 --- a/src/Worker/Core/IVersionedActivityFactory.cs +++ b/src/Worker/Core/IVersionedActivityFactory.cs @@ -7,7 +7,7 @@ namespace Microsoft.DurableTask.Worker; /// /// Creates activity instances by logical name and requested version. -/// Implementations may use an unversioned registration as a compatibility fallback when no exact version match exists. +/// Callers can choose whether an unversioned registration may satisfy a versioned request when no exact match exists. /// internal interface IVersionedActivityFactory { @@ -17,11 +17,16 @@ internal interface IVersionedActivityFactory /// The activity name. /// The activity version. /// The service provider. + /// + /// true to allow an unversioned registration to satisfy a versioned request when no exact match exists; + /// otherwise, false. + /// /// The created activity, if found. /// true if a matching activity was created; otherwise false. bool TryCreateActivity( TaskName name, TaskVersion version, IServiceProvider serviceProvider, + bool allowVersionFallback, [NotNullWhen(true)] out ITaskActivity? activity); } diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index 68947c8c2..9d6c62764 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -128,16 +128,41 @@ public override async Task CallActivityAsync( object? input = null, TaskOptions? options = null) { - static string GetRequestedActivityVersion(TaskOptions? taskOptions, string inheritedVersion) + static (string RequestedVersion, bool ExplicitVersionRequested) GetRequestedActivityVersion( + TaskOptions? taskOptions, + string inheritedVersion) { if (taskOptions is ActivityOptions activityOptions && activityOptions.Version is TaskVersion explicitVersion && !string.IsNullOrWhiteSpace(explicitVersion.Version)) { - return explicitVersion.Version; + return (explicitVersion.Version, true); } - return inheritedVersion; + return (inheritedVersion, false); + } + + static IDictionary GetActivityTags(TaskOptions? taskOptions, bool explicitVersionRequested) + { + Dictionary tags = new(StringComparer.Ordinal); + + if (taskOptions?.Tags is not null) + { + foreach ((string key, string value) in taskOptions.Tags) + { + if (key != ActivityVersioning.ExplicitVersionTagName) + { + tags[key] = value; + } + } + } + + if (explicitVersionRequested) + { + tags[ActivityVersioning.ExplicitVersionTagName] = bool.TrueString; + } + + return tags; } // Since the input parameter takes any object, it's possible that callers may accidentally provide a @@ -154,15 +179,9 @@ static string GetRequestedActivityVersion(TaskOptions? taskOptions, string inher try { - string requestedVersion = GetRequestedActivityVersion(options, this.innerContext.Version); - IDictionary tags = ImmutableDictionary.Empty; - if (options is TaskOptions callActivityOptions) - { - if (callActivityOptions.Tags is not null) - { - tags = callActivityOptions.Tags; - } - } + (string requestedVersion, bool explicitVersionRequested) = + GetRequestedActivityVersion(options, this.innerContext.Version); + IDictionary tags = GetActivityTags(options, explicitVersionRequested); // TODO: Cancellation (https://github.com/microsoft/durabletask-dotnet/issues/7) #pragma warning disable 0618 diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index 9a36fb602..b0e397f93 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -805,13 +805,40 @@ async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, TaskVersion requestedVersion = string.IsNullOrWhiteSpace(request.Version) ? default : new TaskVersion(request.Version); - bool found = this.worker.Factory is IVersionedActivityFactory versionedFactory - ? versionedFactory.TryCreateActivity( + ITaskActivity? activity; + bool found; + if (this.worker.Factory is IVersionedActivityFactory versionedFactory) + { + found = versionedFactory.TryCreateActivity( name, requestedVersion, scope.ServiceProvider, - out ITaskActivity? activity) - : this.worker.Factory.TryCreateActivity(name, scope.ServiceProvider, out activity); + allowVersionFallback: false, + out activity); + + if (!found && !string.IsNullOrWhiteSpace(requestedVersion.Version)) + { + bool explicitVersionRequested = + request.Tags.TryGetValue(ActivityVersioning.ExplicitVersionTagName, out string? tagValue) + && bool.TryParse(tagValue, out bool parsedTagValue) + && parsedTagValue; + bool allowVersionFallback = !explicitVersionRequested; + if (allowVersionFallback) + { + found = versionedFactory.TryCreateActivity( + name, + requestedVersion, + scope.ServiceProvider, + allowVersionFallback: true, + out activity); + } + } + } + else + { + found = this.worker.Factory.TryCreateActivity(name, scope.ServiceProvider, out activity); + } + if (found) { // Both the factory invocation and the RunAsync could involve user code and need to be handled as diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs index 606ccaa6f..bb3598de0 100644 --- a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs @@ -1,7 +1,10 @@ // 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; @@ -81,6 +84,130 @@ public async Task ClassBasedVersionedActivity_ExplicitActivityVersionOverridesOr Assert.Equal("activity-v1:5", metadata.ReadOutputAs()); } + /// + /// Verifies explicit activity version selection does not fall back to an unversioned registration. + /// + [Fact] + public async Task ClassBasedVersionedActivity_ExplicitActivityVersionDoesNotFallBackToUnversionedRegistration() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "ExplicitActivityVersionNoFallbackOrchestration", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("v2"), + }); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Failed, metadata.RuntimeStatus); + Assert.NotNull(metadata.FailureDetails); + Assert.Equal(typeof(TaskFailedException).FullName, metadata.FailureDetails.ErrorType); + Assert.NotNull(metadata.FailureDetails.InnerFailure); + Assert.Equal("ActivityTaskNotFound", metadata.FailureDetails.InnerFailure.ErrorType); + Assert.Contains( + "No activity task named 'ExplicitActivityVersionNoFallbackActivity' with version 'v1' was found.", + metadata.FailureDetails.InnerFailure.ErrorMessage); + } + + /// + /// Verifies inherited orchestration-version activity routing still falls back to an unversioned registration. + /// + [Fact] + public async Task ClassBasedVersionedActivity_InheritedVersionFallsBackToUnversionedRegistration() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "InheritedActivityVersionFallbackOrchestration", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("v2"), + }); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("activity-unversioned:5", metadata.ReadOutputAs()); + } + + /// + /// Verifies user-supplied tags cannot spoof the internal explicit-version marker. + /// + [Fact] + public async Task ClassBasedVersionedActivity_UserSuppliedReservedTagDoesNotDisableInheritedFallback() + { + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + "SpoofedActivityVersionTagFallbackOrchestration", + input: 5, + new StartOrchestrationOptions + { + Version = new TaskVersion("v2"), + }); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("activity-unversioned:5", metadata.ReadOutputAs()); + } + + /// + /// Verifies the in-proc task-scheduled serializer preserves activity tags. + /// + [Fact] + public void TaskScheduledEventSerialization_PreservesExplicitVersionMarker() + { + TaskScheduledEvent scheduledEvent = new( + eventId: 7, + name: "VersionedActivityOverrideActivity", + version: "v1", + input: "5") + { + Tags = new Dictionary + { + [ExplicitVersionTagName] = bool.TrueString, + }, + }; + + var proto = ProtobufUtils.ToHistoryEventProto(scheduledEvent); + + Assert.Equal("VersionedActivityOverrideActivity", proto.TaskScheduled.Name); + Assert.Equal("v1", proto.TaskScheduled.Version); + Assert.True( + proto.TaskScheduled.Tags.TryGetValue(ExplicitVersionTagName, out string? tagValue), + $"Expected tag '{ExplicitVersionTagName}' to be present."); + Assert.Equal(bool.TrueString, tagValue); + } + /// /// Verifies starting without a version fails when only versioned handlers are registered. /// diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs index 382605687..e051e8ff6 100644 --- a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Generic; + namespace Microsoft.DurableTask.Grpc.Tests; /// @@ -8,6 +10,8 @@ namespace Microsoft.DurableTask.Grpc.Tests; /// public static class VersionedClassSyntaxTestOrchestration { + public const string ExplicitVersionTagName = "microsoft.durabletask.activity.explicit-version"; + /// /// Version 1 of the explicit version routing orchestration. /// @@ -74,6 +78,87 @@ public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult($"activity-v2:{input}"); } + /// + /// Version 2 of the orchestration that explicitly requests a missing activity version. + /// + [DurableTask("ExplicitActivityVersionNoFallbackOrchestration")] + [DurableTaskVersion("v2")] + public sealed class ExplicitActivityVersionNoFallbackOrchestrationV2 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => context.CallActivityAsync( + "ExplicitActivityVersionNoFallbackActivity", + input, + new ActivityOptions + { + Version = "v1", + }); + } + + /// + /// Unversioned activity used to verify explicit version requests do not silently fall back. + /// + [DurableTask("ExplicitActivityVersionNoFallbackActivity")] + public sealed class UnversionedActivityVersionNoFallbackActivity : TaskActivity + { + /// + public override Task RunAsync(TaskActivityContext context, int input) + => Task.FromResult($"activity-unversioned:{input}"); + } + + /// + /// Version 2 of the orchestration that inherits its version when calling an unversioned activity. + /// + [DurableTask("InheritedActivityVersionFallbackOrchestration")] + [DurableTaskVersion("v2")] + public sealed class InheritedActivityVersionFallbackOrchestrationV2 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => context.CallActivityAsync("InheritedActivityVersionFallbackActivity", input); + } + + /// + /// Unversioned activity used to verify inherited activity routing retains compatibility fallback behavior. + /// + [DurableTask("InheritedActivityVersionFallbackActivity")] + public sealed class UnversionedInheritedActivityVersionFallbackActivity : TaskActivity + { + /// + public override Task RunAsync(TaskActivityContext context, int input) + => Task.FromResult($"activity-unversioned:{input}"); + } + + /// + /// Version 2 of the orchestration that passes the reserved explicit-version tag in user-supplied task options. + /// + [DurableTask("SpoofedActivityVersionTagFallbackOrchestration")] + [DurableTaskVersion("v2")] + public sealed class SpoofedActivityVersionTagFallbackOrchestrationV2 : TaskOrchestrator + { + /// + public override Task RunAsync(TaskOrchestrationContext context, int input) + => context.CallActivityAsync( + "SpoofedActivityVersionTagFallbackActivity", + input, + new TaskOptions(tags: new Dictionary + { + [ExplicitVersionTagName] = bool.FalseString, + })); + } + + /// + /// Unversioned activity used to verify user-supplied reserved tags do not disable compatibility fallback behavior. + /// + [DurableTask("SpoofedActivityVersionTagFallbackActivity")] + public sealed class UnversionedSpoofedActivityVersionTagFallbackActivity : TaskActivity + { + /// + public override Task RunAsync(TaskActivityContext context, int input) + => Task.FromResult($"activity-unversioned:{input}"); + } + /// /// Version 1 of the continue-as-new orchestration. /// diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index 3a9f4e3f6..2a054485c 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -181,7 +181,7 @@ public void WorkItemFilters_DefaultNullWithVersioningNone_WhenExplicitlyOptedIn( } [Fact] - public void WorkItemFilters_DefaultVersionWithVersioningStrict_DoesNotChangeActivityFilters_WhenExplicitlyOptedIn() + public void WorkItemFilters_DefaultVersionWithVersioningStrict_NarrowsGeneratedFilters_WhenExplicitlyOptedIn() { // Arrange ServiceCollection services = new(); @@ -210,8 +210,52 @@ public void WorkItemFilters_DefaultVersionWithVersioningStrict_DoesNotChangeActi DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); // Assert - actual.Orchestrations.Should().ContainSingle(o => o.Name == nameof(TestOrchestrator) && o.Versions.Count == 0); - actual.Activities.Should().ContainSingle(a => a.Name == nameof(TestActivity) && a.Versions.Count == 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] diff --git a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs index 08d669033..2fc4dc058 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs @@ -19,6 +19,7 @@ public void TryCreateActivity_WithMatchingVersion_ReturnsMatchingImplementation( new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), + allowVersionFallback: true, out ITaskActivity? activity); // Assert @@ -39,6 +40,7 @@ public void TryCreateActivity_WithoutMatchingVersion_ReturnsFalse() new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), + allowVersionFallback: true, out ITaskActivity? activity); // Assert @@ -59,6 +61,7 @@ public void TryCreateActivity_WithRequestedVersion_UsesUnversionedRegistrationWh new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), + allowVersionFallback: true, out ITaskActivity? activity); // Assert @@ -81,6 +84,7 @@ public void TryCreateActivity_WithMixedRegistrations_PrefersExactVersionMatch() new TaskName("InvoiceActivity"), new TaskVersion("v1"), Mock.Of(), + allowVersionFallback: true, out ITaskActivity? activity); // Assert @@ -107,6 +111,27 @@ public void PublicTryCreateActivity_UsesUnversionedRegistrationOnly() activity.Should().BeOfType(); } + [Fact] + public void TryCreateActivity_WithRequestedVersion_DoesNotUseUnversionedRegistrationWhenFallbackIsDisallowed() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + IDurableTaskFactory factory = registry.BuildFactory(); + + // Act + bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + allowVersionFallback: false, + out ITaskActivity? activity); + + // Assert + found.Should().BeFalse(); + activity.Should().BeNull(); + } + [DurableTask("InvoiceActivity")] [DurableTaskVersion("v1")] sealed class InvoiceActivityV1 : TaskActivity diff --git a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs index c71f012f8..4a2b91de7 100644 --- a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs +++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Generic; +using System.Reflection; using DurableTask.Core; using Microsoft.Extensions.Logging.Abstractions; @@ -124,6 +126,9 @@ await wrapper.CallActivityAsync( innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v1"); innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().Contain( + ActivityVersioning.ExplicitVersionTagName, + bool.TrueString); } [Fact] @@ -185,6 +190,31 @@ public async Task CallActivityAsync_PlainTaskOptionsUsesInheritedOrchestrationVe innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); + } + + [Fact] + public async Task CallActivityAsync_UserSuppliedReservedExplicitVersionTagIsIgnoredWhenVersionIsInherited() + { + // Arrange + TrackingOrchestrationContext innerContext = new("v2"); + OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); + TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); + + // Act + await wrapper.CallActivityAsync( + "TestActivity", + 123, + new TaskOptions(tags: new Dictionary + { + [ActivityVersioning.ExplicitVersionTagName] = bool.FalseString, + })); + + // Assert + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be("v2"); + innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); } [Fact] @@ -202,6 +232,7 @@ public async Task CallActivityAsync_NullOptionsUsesInheritedOrchestrationVersion innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); } [Theory] @@ -234,6 +265,13 @@ public async Task CallActivityAsync_MissingOrEmptyActivityVersionUsesInheritedOr innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); + } + + static IReadOnlyDictionary GetLastScheduledTaskTags(TrackingOrchestrationContext innerContext) + { + PropertyInfo tagsProperty = innerContext.LastScheduledTaskOptions!.GetType().GetProperty("Tags")!; + return (IReadOnlyDictionary)tagsProperty.GetValue(innerContext.LastScheduledTaskOptions)!; } sealed class TrackingOrchestrationContext : OrchestrationContext @@ -258,6 +296,8 @@ public TrackingOrchestrationContext(string? version = null) public object? LastScheduledTaskInput { get; private set; } + public ScheduleTaskOptions? LastScheduledTaskOptions { get; private set; } + public override void ContinueAsNew(object input) { this.LastContinueAsNewInput = input; @@ -293,9 +333,13 @@ public override Task ScheduleTask( string version, ScheduleTaskOptions options, params object[] parameters) - => this.CaptureScheduledTask(name, version, parameters); + => this.CaptureScheduledTask(name, version, parameters, options); - Task CaptureScheduledTask(string name, string version, object[] parameters) + Task CaptureScheduledTask( + string name, + string version, + object[] parameters, + ScheduleTaskOptions? options = null) { this.LastScheduledTaskName = name; this.LastScheduledTaskVersion = version; @@ -305,6 +349,7 @@ Task CaptureScheduledTask(string name, string version, object[ 1 => parameters[0], _ => parameters, }; + this.LastScheduledTaskOptions = options; return Task.FromResult(default(TResult)!); } From 08e6c4a9cd945ad78b13e3a6c403114a6288a743 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:35:19 -0700 Subject: [PATCH 35/52] chore: import protobuf contract update Imported src/Grpc/orchestrator_service.proto from microsoft/durabletask-protobuf branch torosent/activity-request-tags (PR #68) instead of carrying the contract change as a local hand edit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Grpc/versions.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Grpc/versions.txt b/src/Grpc/versions.txt index 743f3f8bd..1aff89fe4 100644 --- a/src/Grpc/versions.txt +++ b/src/Grpc/versions.txt @@ -1,2 +1,2 @@ -# The following files were downloaded from branch main at 2026-02-24 00:01:28 UTC -https://raw.githubusercontent.com/microsoft/durabletask-protobuf/1caadbd7ecfdf5f2309acbeac28a3e36d16aa156/protos/orchestrator_service.proto +# The following files were downloaded from branch torosent/activity-request-tags at 2026-04-02 16:34:08 UTC +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/f7f5f57443e52164120be788461a99bafc06c448/protos/orchestrator_service.proto From 5f9dc398d403bf9dfe68031d44fe5260e865e068 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:52:46 -0700 Subject: [PATCH 36/52] chore: refresh protobuf import from main Refresh src/Grpc/orchestrator_service.proto from durabletask-protobuf main after PR #68 merged and update src/Grpc/versions.txt to the mainline source commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Grpc/orchestrator_service.proto | 5 +++++ src/Grpc/versions.txt | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto index 77068612b..022236290 100644 --- a/src/Grpc/orchestrator_service.proto +++ b/src/Grpc/orchestrator_service.proto @@ -321,6 +321,10 @@ message SendEntityMessageAction { } } +message RewindOrchestrationAction { + repeated HistoryEvent newHistory = 1; +} + message OrchestratorAction { int32 id = 1; oneof orchestratorActionType { @@ -331,6 +335,7 @@ message OrchestratorAction { CompleteOrchestrationAction completeOrchestration = 6; TerminateOrchestrationAction terminateOrchestration = 7; SendEntityMessageAction sendEntityMessage = 8; + RewindOrchestrationAction rewindOrchestration = 9; } } diff --git a/src/Grpc/versions.txt b/src/Grpc/versions.txt index 1aff89fe4..47a19588e 100644 --- a/src/Grpc/versions.txt +++ b/src/Grpc/versions.txt @@ -1,2 +1,2 @@ -# The following files were downloaded from branch torosent/activity-request-tags at 2026-04-02 16:34:08 UTC -https://raw.githubusercontent.com/microsoft/durabletask-protobuf/f7f5f57443e52164120be788461a99bafc06c448/protos/orchestrator_service.proto +# The following files were downloaded from branch main at 2026-04-02 16:51:35 UTC +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/e0ee825d632af47b1e754d98cf15b86e4d6a2e9b/protos/orchestrator_service.proto From 38019d7cbaf4e59fc18a1303bce1d9e2c4418624 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Fri, 8 May 2026 08:38:41 -0700 Subject: [PATCH 37/52] Warn at worker startup when per-task and worker-level versioning are combined Addresses gap (4) in proposal #692: per-task [DurableTaskVersion] routing and worker-level UseVersioning() both consume the orchestration instance version field. When combined with MatchStrategy != None the worker-level filter runs before per-task dispatch and silently rejects orchestrations whose instance version does not match the worker version, masking per-task routing. This change emits a single Warning-level log entry (EventId 78) at worker construction when both features are configured, pointing the operator at the docs and clarifying that the two features are not designed to be combined. Implementation notes: - Added DurableTaskRegistry.HasAnyVersionedRegistration() extension in Worker.csproj (which already has IVT to Abstractions internals). - Threaded an optional IOptionsMonitor through the GrpcDurableTaskWorker constructor so the check can run at startup without coupling it to the work-item filter path. - Added three unit tests covering: combined-strict (warns), per-task only (no warn), worker-level only (no warn). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Core/DurableTaskRegistryExtensions.cs | 28 +++++ src/Worker/Grpc/GrpcDurableTaskWorker.cs | 17 ++- src/Worker/Grpc/Logs.cs | 3 + .../Grpc.Tests/GrpcDurableTaskWorkerTests.cs | 106 +++++++++++++++++- 4 files changed, 151 insertions(+), 3 deletions(-) diff --git a/src/Worker/Core/DurableTaskRegistryExtensions.cs b/src/Worker/Core/DurableTaskRegistryExtensions.cs index f89288315..dbac98906 100644 --- a/src/Worker/Core/DurableTaskRegistryExtensions.cs +++ b/src/Worker/Core/DurableTaskRegistryExtensions.cs @@ -18,4 +18,32 @@ public static IDurableTaskFactory BuildFactory(this DurableTaskRegistry registry Check.NotNull(registry); return new DurableTaskFactory(registry.Activities, registry.Orchestrators, registry.Entities); } + + /// + /// Returns a value indicating whether any orchestrator or activity in the registry has been + /// registered with an explicit (non-empty) -style version. + /// + /// The registry to inspect. + /// true if any registration carries a non-empty version; otherwise, false. + internal static bool HasAnyVersionedRegistration(this DurableTaskRegistry registry) + { + Check.NotNull(registry); + foreach (OrchestratorVersionKey key in registry.Orchestrators.Keys) + { + if (!string.IsNullOrWhiteSpace(key.Version)) + { + return true; + } + } + + foreach (ActivityVersionKey key in registry.Activities.Keys) + { + if (!string.IsNullOrWhiteSpace(key.Version)) + { + return true; + } + } + + return false; + } } diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.cs index a200cedbf..1aac58e8f 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.cs @@ -35,6 +35,7 @@ sealed partial class GrpcDurableTaskWorker : DurableTaskWorker /// The optional used to filter orchestration execution. /// The custom exception properties provider that help build failure details. /// The optional used to filter work items in the backend. + /// The optional used to detect per-task versioning configuration. public GrpcDurableTaskWorker( string name, IDurableTaskFactory factory, @@ -44,7 +45,8 @@ public GrpcDurableTaskWorker( ILoggerFactory loggerFactory, IOrchestrationFilter? orchestrationFilter = null, IExceptionPropertiesProvider? exceptionPropertiesProvider = null, - IOptionsMonitor? workItemFiltersMonitor = null) + IOptionsMonitor? workItemFiltersMonitor = null, + IOptionsMonitor? registryMonitor = null) : base(name, factory) { this.grpcOptions = Check.NotNull(grpcOptions).Get(name); @@ -55,6 +57,19 @@ public GrpcDurableTaskWorker( this.orchestrationFilter = orchestrationFilter; this.ExceptionPropertiesProvider = exceptionPropertiesProvider; this.workItemFilters = workItemFiltersMonitor?.Get(name); + + // Surface the misconfiguration of combining worker-level versioning (which gates work items by + // instance version) with per-task [DurableTaskVersion] routing (which expects multiple versions + // of the same logical task to coexist in one worker). The two features both consume the + // orchestration instance version field, so combining them silently masks per-task versions. + if (this.workerOptions.Versioning is DurableTaskWorkerOptions.VersioningOptions versioning + && versioning.MatchStrategy != DurableTaskWorkerOptions.VersionMatchStrategy.None + && registryMonitor is not null + && registryMonitor.Get(name) is DurableTaskRegistry registry + && registry.HasAnyVersionedRegistration()) + { + this.logger.CombinedWorkerAndTaskVersioningWarning(versioning.MatchStrategy.ToString()); + } } /// diff --git a/src/Worker/Grpc/Logs.cs b/src/Worker/Grpc/Logs.cs index 878efe9c8..6f7d020ea 100644 --- a/src/Worker/Grpc/Logs.cs +++ b/src/Worker/Grpc/Logs.cs @@ -52,6 +52,9 @@ static partial class Logs [LoggerMessage(EventId = 56, Level = LogLevel.Warning, Message = "Channel to backend has stopped receiving traffic, will attempt to reconnect.")] public static partial void ConnectionTimeout(this ILogger logger); + [LoggerMessage(EventId = 78, Level = LogLevel.Warning, Message = "Per-task versioning ([DurableTaskVersion]) is configured together with worker-level versioning (UseVersioning with MatchStrategy = '{matchStrategy}'). Worker-level version checks run before per-task dispatch and may reject orchestrations whose instance version does not match the worker version. These features are not designed to be combined; pick one. See https://aka.ms/durabletask-versioning for guidance.")] + public static partial void CombinedWorkerAndTaskVersioningWarning(this ILogger logger, string matchStrategy); + [LoggerMessage(EventId = 57, Level = LogLevel.Warning, Message = "Orchestration version did not meet worker versioning requirements. Error action = '{errorAction}'. Version error = '{versionError}'")] public static partial void OrchestrationVersionFailure(this ILogger logger, string errorAction, string versionError); diff --git a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index db6c98da5..953df0d3e 100644 --- a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs +++ b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs @@ -489,6 +489,87 @@ public async Task ConnectAsync_VeryLargeHelloDeadline_UsesUtcMaxValueDeadline() deadline.Should().Be(DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc)); } + [Fact] + public void Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_LogsWarning() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(new TaskName("MyOrch"), new TaskVersion("1"), () => Mock.Of()); + registry.AddOrchestrator(new TaskName("MyOrch"), new TaskVersion("2"), () => Mock.Of()); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + }, + Logging = { UseLegacyCategories = false }, + }; + TestLogProvider logProvider = new(new NullOutput()); + + // Act + _ = CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, new SimpleLoggerFactory(logProvider), registry); + + // Assert + logProvider.TryGetLogs(Category, out IReadOnlyCollection? logs).Should().BeTrue(); + logs!.Should().Contain(log => + log.LogLevel == LogLevel.Warning + && log.Message.Contains("Per-task versioning") + && log.Message.Contains("worker-level versioning") + && log.Message.Contains("Strict")); + } + + [Fact] + public void Constructor_PerTaskVersioningWithoutWorkerVersioning_DoesNotLogWarning() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(new TaskName("MyOrch"), new TaskVersion("1"), () => Mock.Of()); + DurableTaskWorkerOptions workerOptions = new() + { + Logging = { UseLegacyCategories = false }, + }; + TestLogProvider logProvider = new(new NullOutput()); + + // Act + _ = CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, new SimpleLoggerFactory(logProvider), registry); + + // Assert + if (logProvider.TryGetLogs(Category, out IReadOnlyCollection? logs)) + { + logs!.Should().NotContain(log => + log.Message.Contains("Per-task versioning")); + } + } + + [Fact] + public void Constructor_WorkerVersioningWithoutPerTaskVersions_DoesNotLogWarning() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(new TaskName("MyOrch"), () => Mock.Of()); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + }, + Logging = { UseLegacyCategories = false }, + }; + TestLogProvider logProvider = new(new NullOutput()); + + // Act + _ = CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, new SimpleLoggerFactory(logProvider), registry); + + // Assert + if (logProvider.TryGetLogs(Category, out IReadOnlyCollection? logs)) + { + logs!.Should().NotContain(log => + log.Message.Contains("Per-task versioning")); + } + } + static GrpcDurableTaskWorker CreateWorker(GrpcDurableTaskWorkerOptions grpcOptions) { return CreateWorker(grpcOptions, new DurableTaskWorkerOptions(), NullLoggerFactory.Instance); @@ -500,16 +581,37 @@ static GrpcDurableTaskWorker CreateWorker( ILoggerFactory loggerFactory) { Mock factoryMock = new(MockBehavior.Strict); + return CreateWorker(grpcOptions, workerOptions, loggerFactory, factoryMock.Object, registry: null); + } + static GrpcDurableTaskWorker CreateWorker( + GrpcDurableTaskWorkerOptions grpcOptions, + DurableTaskWorkerOptions workerOptions, + ILoggerFactory loggerFactory, + DurableTaskRegistry registry) + { + Mock factoryMock = new(MockBehavior.Strict); + return CreateWorker(grpcOptions, workerOptions, loggerFactory, factoryMock.Object, registry); + } + + static GrpcDurableTaskWorker CreateWorker( + GrpcDurableTaskWorkerOptions grpcOptions, + DurableTaskWorkerOptions workerOptions, + ILoggerFactory loggerFactory, + IDurableTaskFactory factory, + DurableTaskRegistry? registry) + { 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, + registryMonitor: registry is null ? null : new OptionsMonitorStub(registry)); } static Task InvokeExecuteAsync(GrpcDurableTaskWorker worker, CancellationToken cancellationToken) From 02d14c713acaf4575329c869ccc4506fffb73a85 Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Fri, 8 May 2026 09:25:49 -0700 Subject: [PATCH 38/52] Address group A review findings: collision-resistant suffix, version validation, helper-conflict throw, line endings Closes review findings #1, #5, #6, #8 from PR #695: - ToVersionSuffix collision (#1): the previous encoder left underscore unescaped, so '1.0' and '1_x002E_0' both produced '_1_x002E_0'. Now every non-alphanumeric character (including '_') is escaped as '_xHHHH_', making the encoding injective and avoiding silent generated-helper collisions. - Whitespace version validation (#5): DurableTaskVersionAttribute and the versioned AddOrchestrator/AddActivity overloads now reject whitespace-only version strings (null/empty still means 'unversioned'). The source generator emits a new error diagnostic DURABLE3005 so the misconfiguration is caught at compile time rather than first instance construction. - Helper-conflict throw (#8): generated helpers ApplyGeneratedVersion / ApplyGeneratedActivityVersion now throw InvalidOperationException at runtime when a caller-supplied options.Version disagrees with the version baked into the generator-emitted helper name. Previously the caller's version silently won, defeating the purpose of the version-suffixed method. Updated the generator-output snapshot tests accordingly. - TaskOptions.cs CRLF/LF normalization (#6): the file was the only one in the repo with mixed/CRLF line endings, polluting the PR diff. Normalized to LF to match the rest of the codebase. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DurableTaskRegistry.Orchestrators.cs | 1 + src/Abstractions/DurableTaskRegistry.cs | 15 + .../DurableTaskVersionAttribute.cs | 7 + src/Abstractions/TaskOptions.cs | 396 +++++++++--------- src/Generators/AnalyzerReleases.Unshipped.md | 1 + src/Generators/DurableTaskSourceGenerator.cs | 80 +++- .../VersionedActivityTests.cs | 18 + .../VersionedOrchestratorTests.cs | 80 +++- 8 files changed, 384 insertions(+), 214 deletions(-) diff --git a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs index 80543040a..f2b8791ad 100644 --- a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs +++ b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs @@ -54,6 +54,7 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, TaskVersion version, F { Check.NotDefault(name); Check.NotNull(factory); + ValidateRegistrationVersion(version); OrchestratorVersionKey key = new(name, version); if (this.Orchestrators.ContainsKey(key)) diff --git a/src/Abstractions/DurableTaskRegistry.cs b/src/Abstractions/DurableTaskRegistry.cs index 7937618bb..b9d5de356 100644 --- a/src/Abstractions/DurableTaskRegistry.cs +++ b/src/Abstractions/DurableTaskRegistry.cs @@ -79,6 +79,7 @@ DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func 0 && string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException( + "Version must not be whitespace-only. Provide a non-empty version string or pass default(TaskVersion) to register an unversioned task.", + nameof(version)); + } + } } diff --git a/src/Abstractions/DurableTaskVersionAttribute.cs b/src/Abstractions/DurableTaskVersionAttribute.cs index a8c9108b8..5f4396a7b 100644 --- a/src/Abstractions/DurableTaskVersionAttribute.cs +++ b/src/Abstractions/DurableTaskVersionAttribute.cs @@ -19,6 +19,13 @@ public sealed class DurableTaskVersionAttribute : Attribute /// The version string for the orchestrator or activity. public DurableTaskVersionAttribute(string? version = null) { + if (version is not null && version.Length > 0 && string.IsNullOrWhiteSpace(version)) + { + throw new ArgumentException( + "Version must not be whitespace-only. Provide a non-empty version string or omit the attribute argument to declare an unversioned task.", + nameof(version)); + } + this.Version = string.IsNullOrEmpty(version) ? default : new TaskVersion(version!); } diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index 419b67c16..b8400b4c0 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -1,82 +1,82 @@ -// 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. -/// +// 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. + /// + /// 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); } @@ -131,124 +131,124 @@ public ActivityOptions(ActivityOptions options) /// 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; } -} +{ + /// + /// 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; } +} diff --git a/src/Generators/AnalyzerReleases.Unshipped.md b/src/Generators/AnalyzerReleases.Unshipped.md index 5e333b839..0c83a0100 100644 --- a/src/Generators/AnalyzerReleases.Unshipped.md +++ b/src/Generators/AnalyzerReleases.Unshipped.md @@ -9,3 +9,4 @@ DURABLE3001 | DurableTask.Design | Error | **DurableTaskSourceGenerator**: Repor 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/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index dc6a25f0e..06016677f 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -62,6 +62,11 @@ public class DurableTaskSourceGenerator : IIncrementalGenerator /// 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", @@ -94,6 +99,14 @@ public class DurableTaskSourceGenerator : IIncrementalGenerator 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) { @@ -243,18 +256,32 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } string taskVersion = string.Empty; + Location? taskVersionLocation = null; + bool hasWhitespaceVersion = false; foreach (AttributeData attributeData in classType.GetAttributes()) { if (attributeData.AttributeClass?.ToDisplayString() == "Microsoft.DurableTask.DurableTaskVersionAttribute" && attributeData.ConstructorArguments.Length > 0 && attributeData.ConstructorArguments[0].Value is string version) { - taskVersion = version; + 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); + return new DurableTaskTypeInfo(className, classNamespace, taskName, inputType, outputType, kind, taskVersion, taskNameLocation, taskVersionLocation, hasWhitespaceVersion); } static DurableEventTypeInfo? GetDurableEventTypeInfo(GeneratorSyntaxContext context) @@ -378,6 +405,19 @@ 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) + { + if (task.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) @@ -801,11 +841,15 @@ static string ToVersionSuffix(string version) return string.Empty; } + // Encode the version as a method-name suffix that is collision-free with respect to any other input. + // Letters and digits pass through; '_' and every other character are encoded as "_xHHHH_". Escaping + // '_' itself is what prevents collisions like "1.0" (-> "_1_x002E_0") vs "1_x002E_0" which would + // otherwise both produce "_1_x002E_0". StringBuilder suffixBuilder = new(version.Length + 1); suffixBuilder.Append('_'); foreach (char c in version) { - if (char.IsLetterOrDigit(c) || c == '_') + if (char.IsLetterOrDigit(c)) { suffixBuilder.Append(c); } @@ -897,8 +941,14 @@ static void AddStandaloneGeneratedVersionHelperMethods( sourceBuilder.AppendLine(@" static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: not null and not """" }) + if (options?.Version is { Version: { Length: > 0 } existingVersion }) { + if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + } + return options; } @@ -918,8 +968,14 @@ static void AddStandaloneGeneratedVersionHelperMethods( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) { + if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + } + return options; } @@ -955,6 +1011,12 @@ static void AddStandaloneGeneratedVersionHelperMethods( && activityOptions.Version is TaskVersion explicitVersion && !string.IsNullOrWhiteSpace(explicitVersion.Version)) { + if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version.""); + } + return options; } @@ -1181,7 +1243,9 @@ public DurableTaskTypeInfo( ITypeSymbol? outputType, DurableTaskKind kind, string taskVersion, - Location? taskNameLocation = null) + Location? taskNameLocation = null, + Location? taskVersionLocation = null, + bool hasWhitespaceVersion = false) { this.TypeName = taskType; this.Namespace = taskNamespace; @@ -1189,6 +1253,8 @@ public DurableTaskTypeInfo( this.Kind = kind; this.TaskVersion = taskVersion; this.TaskNameLocation = taskNameLocation; + this.TaskVersionLocation = taskVersionLocation; + this.HasWhitespaceVersion = hasWhitespaceVersion; this.InputTypeSymbol = inputType; this.OutputTypeSymbol = outputType; } @@ -1199,6 +1265,8 @@ public DurableTaskTypeInfo( 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/test/Generators.Tests/VersionedActivityTests.cs b/test/Generators.Tests/VersionedActivityTests.cs index a529284d0..29e0d843b 100644 --- a/test/Generators.Tests/VersionedActivityTests.cs +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -47,6 +47,12 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex && activityOptions.Version is TaskVersion explicitVersion && !string.IsNullOrWhiteSpace(explicitVersion.Version)) { + if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version.""); + } + return options; } @@ -133,6 +139,12 @@ public static Task CallInvoiceActivity_v2Async(this TaskOrchestrationCon && activityOptions.Version is TaskVersion explicitVersion && !string.IsNullOrWhiteSpace(explicitVersion.Version)) { + if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version.""); + } + return options; } @@ -211,6 +223,12 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex && activityOptions.Version is TaskVersion explicitVersion && !string.IsNullOrWhiteSpace(explicitVersion.Version)) { + if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version.""); + } + return options; } diff --git a/test/Generators.Tests/VersionedOrchestratorTests.cs b/test/Generators.Tests/VersionedOrchestratorTests.cs index 29d14f5e2..ec5fc9f1e 100644 --- a/test/Generators.Tests/VersionedOrchestratorTests.cs +++ b/test/Generators.Tests/VersionedOrchestratorTests.cs @@ -52,8 +52,14 @@ public static Task CallInvoiceWorkflowAsync( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: not null and not """" }) + if (options?.Version is { Version: { Length: > 0 } existingVersion }) { + if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + } + return options; } @@ -73,8 +79,14 @@ public static Task CallInvoiceWorkflowAsync( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) { + if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + } + return options; } @@ -179,8 +191,14 @@ public static Task CallInvoiceWorkflow_v2Async( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: not null and not """" }) + if (options?.Version is { Version: { Length: > 0 } existingVersion }) { + if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + } + return options; } @@ -200,8 +218,14 @@ public static Task CallInvoiceWorkflow_v2Async( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) { + if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + } + return options; } @@ -307,8 +331,14 @@ public static Task Callinvoiceworkflow_v2Async( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: not null and not """" }) + if (options?.Version is { Version: { Length: > 0 } existingVersion }) { + if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + } + return options; } @@ -328,8 +358,14 @@ public static Task Callinvoiceworkflow_v2Async( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) { + if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + } + return options; } @@ -415,8 +451,14 @@ public static Task CallInvoiceWorkflowAsync( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: not null and not """" }) + if (options?.Version is { Version: { Length: > 0 } existingVersion }) { + if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + } + return options; } @@ -436,8 +478,14 @@ public static Task CallInvoiceWorkflowAsync( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) { + if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + } + return options; } @@ -539,8 +587,14 @@ public static Task CallInvoiceWorkflowAsync( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: not null and not """" }) + if (options?.Version is { Version: { Length: > 0 } existingVersion }) { + if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + } + return options; } @@ -560,8 +614,14 @@ public static Task CallInvoiceWorkflowAsync( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: not null and not """" } }) + if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) { + if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + { + throw new System.InvalidOperationException( + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + } + return options; } From e4981d057bf30c187c1d35346d2d1c9dd95d6cce Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Fri, 8 May 2026 09:33:24 -0700 Subject: [PATCH 39/52] Address group B review findings: strict orchestrator fallback, explicit-unversioned activity, fail-closed tag, throw on combined versioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes review findings #2, #3, #4, #7 from PR #695: - Orchestrator fallback strictness (#2): DurableTaskFactory.TryCreateOrchestrator no longer falls back to the unversioned registration when at least one versioned registration exists for the same logical name. Previously, scheduling 'PaymentWorkflow' v3 against a registry that had v1, v2, and an unversioned default would silently route the call to the unversioned default. Now this returns 'not found' so the caller is told what actually happened. The unversioned-fallback path is preserved when no versioned registration exists for the name (the migration scenario where pre-versioning instances scheduled with a specific version still need to dispatch). Same gating applied to TryCreateActivity for symmetry on the inherited-fallback path. - Explicit-unversioned activity selection (#3): the API now distinguishes 'inherit' from 'explicit unversioned'. ActivityOptions.Version = null still means inherit; ActivityOptions.Version = TaskVersion.Unversioned (or any non-null TaskVersion, including default) is treated as an explicit selection — even when the version string is empty. This lets a v2 orchestration call the unversioned activity by passing TaskVersion.Unversioned. Added the public TaskVersion.Unversioned static for clarity. - Tag fail-closed (#4): the dispatch-routing tag is renamed from 'microsoft.durabletask.activity.explicit-version' (boolean) to 'microsoft.durabletask.activity.version-source' with values 'explicit' or 'inherited'. The SDK now stamps the tag for both cases (not just explicit), and the worker treats a missing tag on a versioned request as 'explicit' — i.e., strict, no fallback. This means an older sidecar that drops tags can no longer silently degrade strict-explicit semantics to inherited-with-fallback; instead, those calls fail closed with 'activity not found'. - Combined-versioning guard escalated and centralized (#7): the warning-only check on combined UseVersioning() + [DurableTaskVersion] is now a fail-fast InvalidOperationException at worker construction. Moved into a shared WorkerVersioningPolicy.EnsureNotCombined helper in Worker.csproj so any worker subclass (gRPC and any future transport) gets the same check. EventId 78 warning log entry removed. Tests updated to reflect new semantics: - TryCreateOrchestrator_WithMixedRegistrations_DoesNotFallBackForUnknownVersion (replaces old 'UsesUnversionedFallbackForUnknownVersion'). - TryCreateOrchestrator_WithOnlyUnversionedRegistration_FallsBackForVersionedRequest (preserves the migration path). - CallActivityAsync_ExplicitUnversionedActivityOption_BypassesInherit (new b2 coverage). - CallActivityAsync tag assertions updated to the new VersionSourceTagName / ExplicitSource / InheritedSource constants, including a new test that an unversioned-orchestration unversioned-activity stamps no tag at all. - Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_Throws (replaces old 'LogsWarning'). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/TaskVersion.cs | 10 ++ src/Worker/Core/ActivityVersioning.cs | 28 +++++- src/Worker/Core/DurableTaskFactory.cs | 25 ++++- .../Shims/TaskOrchestrationContextWrapper.cs | 43 ++++++--- src/Worker/Core/WorkerVersioningPolicy.cs | 54 +++++++++++ .../Grpc/GrpcDurableTaskWorker.Processor.cs | 30 +++--- src/Worker/Grpc/GrpcDurableTaskWorker.cs | 15 +-- src/Worker/Grpc/Logs.cs | 3 - .../DurableTaskFactoryVersioningTests.cs | 28 +++++- .../TaskOrchestrationContextWrapperTests.cs | 94 +++++++++++++------ .../Grpc.Tests/GrpcDurableTaskWorkerTests.cs | 39 +++----- 11 files changed, 263 insertions(+), 106 deletions(-) create mode 100644 src/Worker/Core/WorkerVersioningPolicy.cs diff --git a/src/Abstractions/TaskVersion.cs b/src/Abstractions/TaskVersion.cs index 5d6d1bfec..35c903880 100644 --- a/src/Abstractions/TaskVersion.cs +++ b/src/Abstractions/TaskVersion.cs @@ -8,6 +8,16 @@ namespace Microsoft.DurableTask; /// public readonly struct TaskVersion : IEquatable { + /// + /// A sentinel value representing an unversioned task. Equivalent to default(TaskVersion). + /// + /// + /// Use this on to explicitly request the unversioned activity + /// implementation from a versioned orchestration. null on the same property means the activity + /// inherits the orchestration instance version. + /// + public static readonly TaskVersion Unversioned = default; + /// /// Initializes a new instance of the struct. /// diff --git a/src/Worker/Core/ActivityVersioning.cs b/src/Worker/Core/ActivityVersioning.cs index 354bb8cdb..c087252e0 100644 --- a/src/Worker/Core/ActivityVersioning.cs +++ b/src/Worker/Core/ActivityVersioning.cs @@ -9,7 +9,31 @@ namespace Microsoft.DurableTask.Worker; static class ActivityVersioning { /// - /// Internal tag stamped on scheduled activity events when the caller explicitly chooses an activity version. + /// Internal tag stamped on scheduled activity events to communicate whether the requested activity version + /// is an explicit caller-supplied selection or an inherited orchestration-instance version. The worker reads + /// this tag to choose between strict dispatch (no fallback) and inherited dispatch (fallback to the + /// unversioned registration is allowed). When the tag is missing on a versioned request, the worker fails + /// closed (treats it as ) so a sidecar that drops tags cannot silently degrade + /// strict-explicit semantics to inherited-fallback. /// - internal const string ExplicitVersionTagName = "microsoft.durabletask.activity.explicit-version"; + internal const string VersionSourceTagName = "microsoft.durabletask.activity.version-source"; + + /// + /// Tag value indicating the caller explicitly chose this activity version via . + /// Strict dispatch — no unversioned fallback. + /// + internal const string ExplicitSource = "explicit"; + + /// + /// Tag value indicating the activity inherited its version from the orchestration instance. Inherited + /// dispatch — fallback to the unversioned registration is allowed for backward compatibility. + /// + internal const string InheritedSource = "inherited"; + + /// + /// All reserved version-source tag keys. Stripped from caller-supplied to + /// prevent spoofing of the dispatch contract. + /// + internal static readonly string[] ReservedTagKeys = { VersionSourceTagName }; } + diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 8216f9752..786aa332f 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -14,6 +14,8 @@ sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedActivityFactor readonly IDictionary> activities; readonly IDictionary> orchestrators; readonly IDictionary> entities; + readonly HashSet versionedOrchestratorNames; + readonly HashSet versionedActivityNames; /// /// Initializes a new instance of the class. @@ -29,6 +31,21 @@ internal DurableTaskFactory( 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); } /// @@ -49,6 +66,7 @@ public bool TryCreateActivity( if (allowVersionFallback && !string.IsNullOrWhiteSpace(version.Version) + && !this.versionedActivityNames.Contains(name.Name) && this.activities.TryGetValue(new ActivityVersionKey(name, default(TaskVersion)), out factory)) { activity = factory.Invoke(serviceProvider); @@ -79,9 +97,12 @@ public bool TryCreateOrchestrator( return true; } - // Unversioned registrations remain the compatibility fallback when a caller requests a version that has - // no exact match for the logical orchestrator name. + // 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 OrchestratorVersionKey(name, default(TaskVersion)), out factory)) { orchestrator = factory.Invoke(serviceProvider); diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index affe5ad53..ac9faa5a9 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -129,21 +129,30 @@ public override async Task CallActivityAsync( object? input = null, TaskOptions? options = null) { - static (string RequestedVersion, bool ExplicitVersionRequested) GetRequestedActivityVersion( + // Returns (effective version string, whether the caller-supplied an explicit version, whether the + // call should be tagged at all). The "tag the call" decision is separate from "explicit": even a + // pure inherited call needs a tag so the worker can fail-closed on a missing tag for a versioned + // request. + static (string RequestedVersion, bool ExplicitVersionRequested, bool StampVersionTag) GetRequestedActivityVersion( TaskOptions? taskOptions, string inheritedVersion) { if (taskOptions is ActivityOptions activityOptions - && activityOptions.Version is TaskVersion explicitVersion - && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + && activityOptions.Version is TaskVersion explicitVersion) { - return (explicitVersion.Version, true); + // Any non-null ActivityOptions.Version — including an explicit-unversioned request + // (TaskVersion.Unversioned / default) — is treated as an explicit selection. Strict + // dispatch will be used. + return (explicitVersion.Version ?? string.Empty, true, true); } - return (inheritedVersion, false); + return (inheritedVersion, false, !string.IsNullOrWhiteSpace(inheritedVersion)); } - static IDictionary GetActivityTags(TaskOptions? taskOptions, bool explicitVersionRequested) + static IDictionary GetActivityTags( + TaskOptions? taskOptions, + bool explicitVersionRequested, + bool stampVersionTag) { Dictionary tags = new(StringComparer.Ordinal); @@ -151,16 +160,28 @@ static IDictionary GetActivityTags(TaskOptions? taskOptions, boo { foreach ((string key, string value) in taskOptions.Tags) { - if (key != ActivityVersioning.ExplicitVersionTagName) + bool isReserved = false; + foreach (string reserved in ActivityVersioning.ReservedTagKeys) + { + if (string.Equals(key, reserved, StringComparison.Ordinal)) + { + isReserved = true; + break; + } + } + + if (!isReserved) { tags[key] = value; } } } - if (explicitVersionRequested) + if (stampVersionTag) { - tags[ActivityVersioning.ExplicitVersionTagName] = bool.TrueString; + tags[ActivityVersioning.VersionSourceTagName] = explicitVersionRequested + ? ActivityVersioning.ExplicitSource + : ActivityVersioning.InheritedSource; } return tags; @@ -180,9 +201,9 @@ static IDictionary GetActivityTags(TaskOptions? taskOptions, boo try { - (string requestedVersion, bool explicitVersionRequested) = + (string requestedVersion, bool explicitVersionRequested, bool stampVersionTag) = GetRequestedActivityVersion(options, this.innerContext.Version); - IDictionary tags = GetActivityTags(options, explicitVersionRequested); + IDictionary tags = GetActivityTags(options, explicitVersionRequested, stampVersionTag); // TODO: Cancellation (https://github.com/microsoft/durabletask-dotnet/issues/7) #pragma warning disable 0618 diff --git a/src/Worker/Core/WorkerVersioningPolicy.cs b/src/Worker/Core/WorkerVersioningPolicy.cs new file mode 100644 index 000000000..e4fcc76a0 --- /dev/null +++ b/src/Worker/Core/WorkerVersioningPolicy.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker; + +/// +/// Worker startup checks that all subclasses should perform before they +/// begin processing work items. Centralizing the checks keeps every transport (gRPC, in-proc, future +/// transports) on the same set of guarantees. +/// +internal static class WorkerVersioningPolicy +{ + /// + /// Throws when worker-level is configured + /// alongside per-task [DurableTaskVersion] registrations. The two features both consume the + /// orchestration instance version field; combining them silently masks per-task routing because the + /// worker-level filter rejects versioned work items before per-task dispatch can run. + /// + /// The worker name (for the diagnostic message). + /// The worker options. + /// The registry, or null if the worker did not opt into the check. + /// + /// Thrown when both worker-level and per-task versioning are configured. + /// + public static void EnsureNotCombined( + string workerName, + DurableTaskWorkerOptions workerOptions, + DurableTaskRegistry? registry) + { + Check.NotNull(workerOptions); + if (registry is null) + { + return; + } + + if (workerOptions.Versioning is not DurableTaskWorkerOptions.VersioningOptions versioning + || versioning.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.None) + { + return; + } + + if (!registry.HasAnyVersionedRegistration()) + { + return; + } + + throw new InvalidOperationException( + $"Worker '{workerName}' has both worker-level versioning (UseVersioning with MatchStrategy = '{versioning.MatchStrategy}') " + + $"and per-task [DurableTaskVersion] registrations configured. These features are not designed to be combined: " + + $"worker-level version checks run before per-task dispatch and will reject orchestrations whose instance version " + + $"does not match the worker version, silently masking per-task routing. Pick one. " + + $"See https://aka.ms/durabletask-versioning for guidance."); + } +} diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index 196e6c2ff..db09a05a8 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -903,30 +903,22 @@ async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, bool found; if (this.worker.Factory is IVersionedActivityFactory versionedFactory) { + // Read the version-source tag from the request to decide between strict and inherited + // dispatch. Fail-closed semantics: missing tag on a versioned request is treated as + // explicit (no fallback), so a sidecar that drops tags cannot silently downgrade + // strict-explicit semantics to inherited-with-fallback. + bool tagPresent = request.Tags.TryGetValue( + ActivityVersioning.VersionSourceTagName, out string? tagValue); + bool tagSaysInherited = tagPresent + && string.Equals(tagValue, ActivityVersioning.InheritedSource, StringComparison.Ordinal); + bool allowVersionFallback = tagSaysInherited; + found = versionedFactory.TryCreateActivity( name, requestedVersion, scope.ServiceProvider, - allowVersionFallback: false, + allowVersionFallback, out activity); - - if (!found && !string.IsNullOrWhiteSpace(requestedVersion.Version)) - { - bool explicitVersionRequested = - request.Tags.TryGetValue(ActivityVersioning.ExplicitVersionTagName, out string? tagValue) - && bool.TryParse(tagValue, out bool parsedTagValue) - && parsedTagValue; - bool allowVersionFallback = !explicitVersionRequested; - if (allowVersionFallback) - { - found = versionedFactory.TryCreateActivity( - name, - requestedVersion, - scope.ServiceProvider, - allowVersionFallback: true, - out activity); - } - } } else { diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.cs index 1aac58e8f..34baecde6 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.cs @@ -58,18 +58,9 @@ public GrpcDurableTaskWorker( this.ExceptionPropertiesProvider = exceptionPropertiesProvider; this.workItemFilters = workItemFiltersMonitor?.Get(name); - // Surface the misconfiguration of combining worker-level versioning (which gates work items by - // instance version) with per-task [DurableTaskVersion] routing (which expects multiple versions - // of the same logical task to coexist in one worker). The two features both consume the - // orchestration instance version field, so combining them silently masks per-task versions. - if (this.workerOptions.Versioning is DurableTaskWorkerOptions.VersioningOptions versioning - && versioning.MatchStrategy != DurableTaskWorkerOptions.VersionMatchStrategy.None - && registryMonitor is not null - && registryMonitor.Get(name) is DurableTaskRegistry registry - && registry.HasAnyVersionedRegistration()) - { - this.logger.CombinedWorkerAndTaskVersioningWarning(versioning.MatchStrategy.ToString()); - } + // Fail fast when worker-level versioning is combined with per-task [DurableTaskVersion] registrations. + // The two features are not designed to coexist (see WorkerVersioningPolicy for details). + WorkerVersioningPolicy.EnsureNotCombined(name, this.workerOptions, registryMonitor?.Get(name)); } /// diff --git a/src/Worker/Grpc/Logs.cs b/src/Worker/Grpc/Logs.cs index 6f7d020ea..878efe9c8 100644 --- a/src/Worker/Grpc/Logs.cs +++ b/src/Worker/Grpc/Logs.cs @@ -52,9 +52,6 @@ static partial class Logs [LoggerMessage(EventId = 56, Level = LogLevel.Warning, Message = "Channel to backend has stopped receiving traffic, will attempt to reconnect.")] public static partial void ConnectionTimeout(this ILogger logger); - [LoggerMessage(EventId = 78, Level = LogLevel.Warning, Message = "Per-task versioning ([DurableTaskVersion]) is configured together with worker-level versioning (UseVersioning with MatchStrategy = '{matchStrategy}'). Worker-level version checks run before per-task dispatch and may reject orchestrations whose instance version does not match the worker version. These features are not designed to be combined; pick one. See https://aka.ms/durabletask-versioning for guidance.")] - public static partial void CombinedWorkerAndTaskVersioningWarning(this ILogger logger, string matchStrategy); - [LoggerMessage(EventId = 57, Level = LogLevel.Warning, Message = "Orchestration version did not meet worker versioning requirements. Error action = '{errorAction}'. Version error = '{versionError}'")] public static partial void OrchestrationVersionFailure(this ILogger logger, string errorAction, string versionError); diff --git a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs index b737866cb..20cc9ffa4 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -89,9 +89,11 @@ public void TryCreateOrchestrator_WithMixedRegistrations_PrefersExactVersionMatc } [Fact] - public void TryCreateOrchestrator_WithMixedRegistrations_UsesUnversionedFallbackForUnknownVersion() + public void TryCreateOrchestrator_WithMixedRegistrations_DoesNotFallBackForUnknownVersion() { - // Arrange + // 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(); @@ -105,6 +107,28 @@ public void TryCreateOrchestrator_WithMixedRegistrations_UsesUnversionedFallback 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 = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v1"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + // Assert found.Should().BeTrue(); orchestrator.Should().BeOfType(); diff --git a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs index 2c3e4d6d5..006675ed1 100644 --- a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs +++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs @@ -152,8 +152,8 @@ await wrapper.CallActivityAsync( innerContext.LastScheduledTaskVersion.Should().Be("v1"); innerContext.LastScheduledTaskInput.Should().Be(123); GetLastScheduledTaskTags(innerContext).Should().Contain( - ActivityVersioning.ExplicitVersionTagName, - bool.TrueString); + ActivityVersioning.VersionSourceTagName, + ActivityVersioning.ExplicitSource); } [Fact] @@ -215,31 +215,38 @@ public async Task CallActivityAsync_PlainTaskOptionsUsesInheritedOrchestrationVe innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); innerContext.LastScheduledTaskInput.Should().Be(123); - GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); + // An inherited non-empty version stamps the source as Inherited so the worker can fail-closed + // when a sidecar drops tags rather than silently degrading strict-explicit semantics. + GetLastScheduledTaskTags(innerContext).Should().Contain( + ActivityVersioning.VersionSourceTagName, + ActivityVersioning.InheritedSource); } [Fact] - public async Task CallActivityAsync_UserSuppliedReservedExplicitVersionTagIsIgnoredWhenVersionIsInherited() + public async Task CallActivityAsync_UserSuppliedReservedVersionSourceTagIsStripped() { // Arrange TrackingOrchestrationContext innerContext = new("v2"); OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); - // Act + // Act — the user attempts to spoof "explicit" via Tags. The SDK strips the reserved key and + // re-stamps according to the actual options (inherited here). await wrapper.CallActivityAsync( "TestActivity", 123, new TaskOptions(tags: new Dictionary { - [ActivityVersioning.ExplicitVersionTagName] = bool.FalseString, + [ActivityVersioning.VersionSourceTagName] = ActivityVersioning.ExplicitSource, })); // Assert innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); innerContext.LastScheduledTaskInput.Should().Be(123); - GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); + GetLastScheduledTaskTags(innerContext).Should().Contain( + ActivityVersioning.VersionSourceTagName, + ActivityVersioning.InheritedSource); } [Fact] @@ -257,40 +264,69 @@ public async Task CallActivityAsync_NullOptionsUsesInheritedOrchestrationVersion innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); innerContext.LastScheduledTaskInput.Should().Be(123); - GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); + GetLastScheduledTaskTags(innerContext).Should().Contain( + ActivityVersioning.VersionSourceTagName, + ActivityVersioning.InheritedSource); } - [Theory] - [InlineData(false, null)] - [InlineData(true, null)] - [InlineData(true, "")] - [InlineData(true, " ")] - public async Task CallActivityAsync_MissingOrEmptyActivityVersionUsesInheritedOrchestrationVersion( - bool setVersion, - string? explicitVersion) + [Fact] + public async Task CallActivityAsync_NullOptionsAndUnversionedOrchestration_StampsNoTag() { - // Arrange - TrackingOrchestrationContext innerContext = new("v2"); + // Arrange — orchestration is unversioned (no instance version). No explicit option either. + TrackingOrchestrationContext innerContext = new(); OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); - ActivityOptions options = new(); - if (setVersion) - { - options = options with - { - Version = explicitVersion is null ? default(TaskVersion?) : new TaskVersion(explicitVersion), - }; - } + // Act + await wrapper.CallActivityAsync("TestActivity", 123); + + // Assert — no version, no tag stamped (matches pre-versioning behavior). + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be(string.Empty); + innerContext.LastScheduledTaskInput.Should().Be(123); + GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.VersionSourceTagName); + } + + [Fact] + public async Task CallActivityAsync_NullActivityOptionsVersion_InheritsOrchestrationVersion() + { + // Arrange — ActivityOptions 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, options); + await wrapper.CallActivityAsync("TestActivity", 123, new ActivityOptions()); // Assert innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); - innerContext.LastScheduledTaskInput.Should().Be(123); - GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.ExplicitVersionTagName); + GetLastScheduledTaskTags(innerContext).Should().Contain( + ActivityVersioning.VersionSourceTagName, + ActivityVersioning.InheritedSource); + } + + [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 ActivityOptions { Version = TaskVersion.Unversioned }); + + // Assert — empty version is sent (the unversioned activity), but the tag identifies it as explicit + // so the worker uses strict dispatch instead of inheriting the orchestration's v2 version. + innerContext.LastScheduledTaskName.Should().Be("TestActivity"); + innerContext.LastScheduledTaskVersion.Should().Be(string.Empty); + GetLastScheduledTaskTags(innerContext).Should().Contain( + ActivityVersioning.VersionSourceTagName, + ActivityVersioning.ExplicitSource); } static IReadOnlyDictionary GetLastScheduledTaskTags(TrackingOrchestrationContext innerContext) diff --git a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index 953df0d3e..5df9d57d8 100644 --- a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs +++ b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs @@ -490,7 +490,7 @@ public async Task ConnectAsync_VeryLargeHelloDeadline_UsesUtcMaxValueDeadline() } [Fact] - public void Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_LogsWarning() + public void Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_Throws() { // Arrange DurableTaskRegistry registry = new(); @@ -505,22 +505,19 @@ public void Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_Logs }, Logging = { UseLegacyCategories = false }, }; - TestLogProvider logProvider = new(new NullOutput()); // Act - _ = CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, new SimpleLoggerFactory(logProvider), registry); + Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance, registry); - // Assert - logProvider.TryGetLogs(Category, out IReadOnlyCollection? logs).Should().BeTrue(); - logs!.Should().Contain(log => - log.LogLevel == LogLevel.Warning - && log.Message.Contains("Per-task versioning") - && log.Message.Contains("worker-level versioning") - && log.Message.Contains("Strict")); + // Assert — combined configuration fails fast at worker construction. + act.Should().Throw() + .WithMessage("*per-task [DurableTaskVersion]*") + .WithMessage("*worker-level versioning*") + .WithMessage("*Strict*"); } [Fact] - public void Constructor_PerTaskVersioningWithoutWorkerVersioning_DoesNotLogWarning() + public void Constructor_PerTaskVersioningWithoutWorkerVersioning_DoesNotThrow() { // Arrange DurableTaskRegistry registry = new(); @@ -529,21 +526,16 @@ public void Constructor_PerTaskVersioningWithoutWorkerVersioning_DoesNotLogWarni { Logging = { UseLegacyCategories = false }, }; - TestLogProvider logProvider = new(new NullOutput()); // Act - _ = CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, new SimpleLoggerFactory(logProvider), registry); + Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance, registry); // Assert - if (logProvider.TryGetLogs(Category, out IReadOnlyCollection? logs)) - { - logs!.Should().NotContain(log => - log.Message.Contains("Per-task versioning")); - } + act.Should().NotThrow(); } [Fact] - public void Constructor_WorkerVersioningWithoutPerTaskVersions_DoesNotLogWarning() + public void Constructor_WorkerVersioningWithoutPerTaskVersions_DoesNotThrow() { // Arrange DurableTaskRegistry registry = new(); @@ -557,17 +549,12 @@ public void Constructor_WorkerVersioningWithoutPerTaskVersions_DoesNotLogWarning }, Logging = { UseLegacyCategories = false }, }; - TestLogProvider logProvider = new(new NullOutput()); // Act - _ = CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, new SimpleLoggerFactory(logProvider), registry); + Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance, registry); // Assert - if (logProvider.TryGetLogs(Category, out IReadOnlyCollection? logs)) - { - logs!.Should().NotContain(log => - log.Message.Contains("Per-task versioning")); - } + act.Should().NotThrow(); } static GrpcDurableTaskWorker CreateWorker(GrpcDurableTaskWorkerOptions grpcOptions) From bd4b8dbada99c4e387e77997e2bf3a1c9deeba3a Mon Sep 17 00:00:00 2001 From: torosent <17064840+torosent@users.noreply.github.com> Date: Fri, 8 May 2026 10:17:38 -0700 Subject: [PATCH 40/52] Address re-review findings #1-4, 7-8: explicit-unversioned helper conflict, TaskVersion null-safety, integration test refresh, migration recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second-pass review findings from PR #695: #1 — Stale tag references in integration tests: VersionedClassSyntaxTestOrchestration.cs and VersionedClassSyntaxIntegrationTests.cs were still using the pre-rename 'microsoft.durabletask.activity.explicit-version' constant. The spoof-protection test in particular was passing for the wrong reason since the SDK no longer recognizes that key. Updated both files to use ActivityVersioning.VersionSourceTagName / ExplicitSource / InheritedSource and added IVT for Worker.Grpc.IntegrationTests in Worker.csproj so the integration tests can reference the SDK constants directly. #2 — Generator helper conflict check missed explicit-unversioned: ApplyGeneratedVersion (StartOrchestrationOptions / SubOrchestrationOptions) and ApplyGeneratedActivityVersion all used patterns that required '.Version.Length > 0' or '!IsNullOrWhiteSpace', which excluded TaskVersion.Unversioned. A user calling CallProcessPayment_2Async(..., new ActivityOptions { Version = TaskVersion.Unversioned }) silently got v2 instead of the contradicting throw the helper was supposed to enforce. Now the patterns match any non-null Version (including the empty/unversioned case) and the diagnostic message distinguishes 'explicit-unversioned' from a specific version. Generator-output snapshot tests updated. #3 — TaskVersion null-storage caused null/empty mismatch and GetHashCode crash: the constructor stored 'null' verbatim when given null. Effects: TaskVersion.Unversioned.Equals(new TaskVersion('')) was false, and TaskVersion.Unversioned.GetHashCode() called StringComparer.OrdinalIgnoreCase.GetHashCode(null) which throws — making any user who put TaskVersion in a dictionary key crash at runtime. Constructor now normalizes null/empty to string.Empty; Equals and GetHashCode are null-safe and treat null and empty as identical. (default(TaskVersion) still has Version=null at the field level — that's a struct-default constraint — but Equals/GetHashCode handle it.) #4 — Whitespace TaskVersion ctor validation: the registry rejected whitespace-only TaskVersion, but new TaskVersion(' ') itself accepted the value, so it could leak into ActivityOptions / StartOrchestrationOptions / SubOrchestrationOptions and route silently to 'no exact match'. The constructor now throws ArgumentException for non-empty whitespace. The redundant ValidateRegistrationVersion method and the explicit whitespace check in DurableTaskVersionAttribute were removed because TaskVersion's constructor handles them centrally. #7 — Migration recipe: added a new section to the proposal documenting the 'add [DurableTaskVersion] to an existing class' migration path. Recommended recipe is keep the unversioned class registered until in-flight unversioned instances drain or ContinueAsNew to the new version. Reverse migration (removing [DurableTaskVersion]) is documented as unsupported. #8 — Tag rename pre-release note: added a one-paragraph note in the proposal acknowledging the in-flight rename from 'explicit-version' (boolean) to 'version-source' ('explicit'/'inherited') and recommending pre-release deployments drain before pointing at the new contract since the worker now fail-closes on missing tag for versioned activity requests. Tests: 84 generator + 127 worker.tests + 135 worker.grpc.tests + 159 abstractions.tests + 8 integration = 513 total, all passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DurableTaskRegistry.Orchestrators.cs | 1 - src/Abstractions/DurableTaskRegistry.cs | 15 --- .../DurableTaskVersionAttribute.cs | 11 +- src/Abstractions/TaskVersion.cs | 44 ++++++-- src/Generators/DurableTaskSourceGenerator.cs | 34 ++++-- src/Worker/Core/Worker.csproj | 1 + .../VersionedActivityTests.cs | 36 ++++--- .../VersionedOrchestratorTests.cs | 102 ++++++++++++------ .../VersionedClassSyntaxIntegrationTests.cs | 13 +-- .../VersionedClassSyntaxTestOrchestration.cs | 12 ++- 10 files changed, 172 insertions(+), 97 deletions(-) diff --git a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs index f2b8791ad..80543040a 100644 --- a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs +++ b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs @@ -54,7 +54,6 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, TaskVersion version, F { Check.NotDefault(name); Check.NotNull(factory); - ValidateRegistrationVersion(version); OrchestratorVersionKey key = new(name, version); if (this.Orchestrators.ContainsKey(key)) diff --git a/src/Abstractions/DurableTaskRegistry.cs b/src/Abstractions/DurableTaskRegistry.cs index b9d5de356..7937618bb 100644 --- a/src/Abstractions/DurableTaskRegistry.cs +++ b/src/Abstractions/DurableTaskRegistry.cs @@ -79,7 +79,6 @@ DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func 0 && string.IsNullOrWhiteSpace(value)) - { - throw new ArgumentException( - "Version must not be whitespace-only. Provide a non-empty version string or pass default(TaskVersion) to register an unversioned task.", - nameof(version)); - } - } } diff --git a/src/Abstractions/DurableTaskVersionAttribute.cs b/src/Abstractions/DurableTaskVersionAttribute.cs index 5f4396a7b..5742e7b28 100644 --- a/src/Abstractions/DurableTaskVersionAttribute.cs +++ b/src/Abstractions/DurableTaskVersionAttribute.cs @@ -19,14 +19,9 @@ public sealed class DurableTaskVersionAttribute : Attribute /// The version string for the orchestrator or activity. public DurableTaskVersionAttribute(string? version = null) { - if (version is not null && version.Length > 0 && string.IsNullOrWhiteSpace(version)) - { - throw new ArgumentException( - "Version must not be whitespace-only. Provide a non-empty version string or omit the attribute argument to declare an unversioned task.", - nameof(version)); - } - - this.Version = string.IsNullOrEmpty(version) ? default : new TaskVersion(version!); + // 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!); } /// diff --git a/src/Abstractions/TaskVersion.cs b/src/Abstractions/TaskVersion.cs index 35c903880..96c635b34 100644 --- a/src/Abstractions/TaskVersion.cs +++ b/src/Abstractions/TaskVersion.cs @@ -9,7 +9,8 @@ namespace Microsoft.DurableTask; public readonly struct TaskVersion : IEquatable { /// - /// A sentinel value representing an unversioned task. Equivalent to default(TaskVersion). + /// A sentinel value representing an unversioned task. Equivalent to default(TaskVersion) and + /// new TaskVersion(string.Empty). /// /// /// Use this on to explicitly request the unversioned activity @@ -21,21 +22,38 @@ namespace Microsoft.DurableTask; /// /// 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. Without this normalization the struct's + // Version field can be null, which makes Equals(null, "") return false and causes + // StringComparer.OrdinalIgnoreCase.GetHashCode to throw at runtime when the struct is used as a + // dictionary key. + if (string.IsNullOrEmpty(version)) { - this.Version = null!; + this.Version = 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.Version = version; } /// - /// Gets the version of a task. + /// Gets the version of a task. Returns for an unversioned task; never + /// returns null. /// public string Version { get; } @@ -81,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); } /// @@ -106,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/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 06016677f..67402f9af 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -941,12 +941,18 @@ static void AddStandaloneGeneratedVersionHelperMethods( sourceBuilder.AppendLine(@" static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: { Length: > 0 } existingVersion }) - { - if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + if (options?.Version is TaskVersion existingStart) + { + // Any non-null Version on the options is an explicit caller selection — including + // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a + // specific version into its method name, so a disagreement is always a contradiction; + // the silently-overridden case is precisely what we are trying to prevent. + string existingValue = existingStart.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); } return options; @@ -968,12 +974,14 @@ static void AddStandaloneGeneratedVersionHelperMethods( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) + if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) { - if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + string existingValue = existingSub.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); } return options; @@ -1008,13 +1016,17 @@ static void AddStandaloneGeneratedVersionHelperMethods( static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { if (options is ActivityOptions activityOptions - && activityOptions.Version is TaskVersion explicitVersion - && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + && activityOptions.Version is TaskVersion explicitVersion) { - if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase)) + // Any non-null ActivityOptions.Version is an explicit caller selection — including + // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- + // baked version is always a contradiction, so we throw rather than silently override. + string explicitValue = explicitVersion.Version ?? string.Empty; + if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version.""); + $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); } return options; diff --git a/src/Worker/Core/Worker.csproj b/src/Worker/Core/Worker.csproj index c4832a162..131a98785 100644 --- a/src/Worker/Core/Worker.csproj +++ b/src/Worker/Core/Worker.csproj @@ -21,6 +21,7 @@ The worker is responsible for processing durable task work items. + diff --git a/test/Generators.Tests/VersionedActivityTests.cs b/test/Generators.Tests/VersionedActivityTests.cs index 29e0d843b..8f2f97bd7 100644 --- a/test/Generators.Tests/VersionedActivityTests.cs +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -44,13 +44,17 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { if (options is ActivityOptions activityOptions - && activityOptions.Version is TaskVersion explicitVersion - && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + && activityOptions.Version is TaskVersion explicitVersion) { - if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase)) + // Any non-null ActivityOptions.Version is an explicit caller selection — including + // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- + // baked version is always a contradiction, so we throw rather than silently override. + string explicitValue = explicitVersion.Version ?? string.Empty; + if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version.""); + $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); } return options; @@ -136,13 +140,17 @@ public static Task CallInvoiceActivity_v2Async(this TaskOrchestrationCon static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { if (options is ActivityOptions activityOptions - && activityOptions.Version is TaskVersion explicitVersion - && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + && activityOptions.Version is TaskVersion explicitVersion) { - if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase)) + // Any non-null ActivityOptions.Version is an explicit caller selection — including + // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- + // baked version is always a contradiction, so we throw rather than silently override. + string explicitValue = explicitVersion.Version ?? string.Empty; + if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version.""); + $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); } return options; @@ -220,13 +228,17 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { if (options is ActivityOptions activityOptions - && activityOptions.Version is TaskVersion explicitVersion - && !string.IsNullOrWhiteSpace(explicitVersion.Version)) + && activityOptions.Version is TaskVersion explicitVersion) { - if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase)) + // Any non-null ActivityOptions.Version is an explicit caller selection — including + // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- + // baked version is always a contradiction, so we throw rather than silently override. + string explicitValue = explicitVersion.Version ?? string.Empty; + if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version.""); + $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); } return options; diff --git a/test/Generators.Tests/VersionedOrchestratorTests.cs b/test/Generators.Tests/VersionedOrchestratorTests.cs index ec5fc9f1e..f81d23042 100644 --- a/test/Generators.Tests/VersionedOrchestratorTests.cs +++ b/test/Generators.Tests/VersionedOrchestratorTests.cs @@ -52,12 +52,18 @@ public static Task CallInvoiceWorkflowAsync( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: { Length: > 0 } existingVersion }) - { - if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + if (options?.Version is TaskVersion existingStart) + { + // Any non-null Version on the options is an explicit caller selection — including + // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a + // specific version into its method name, so a disagreement is always a contradiction; + // the silently-overridden case is precisely what we are trying to prevent. + string existingValue = existingStart.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); } return options; @@ -79,12 +85,14 @@ public static Task CallInvoiceWorkflowAsync( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) + if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) { - if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + string existingValue = existingSub.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); } return options; @@ -191,12 +199,18 @@ public static Task CallInvoiceWorkflow_v2Async( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: { Length: > 0 } existingVersion }) + if (options?.Version is TaskVersion existingStart) { - if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + // Any non-null Version on the options is an explicit caller selection — including + // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a + // specific version into its method name, so a disagreement is always a contradiction; + // the silently-overridden case is precisely what we are trying to prevent. + string existingValue = existingStart.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); } return options; @@ -218,12 +232,14 @@ public static Task CallInvoiceWorkflow_v2Async( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) + if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) { - if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + string existingValue = existingSub.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); } return options; @@ -331,12 +347,18 @@ public static Task Callinvoiceworkflow_v2Async( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: { Length: > 0 } existingVersion }) + if (options?.Version is TaskVersion existingStart) { - if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + // Any non-null Version on the options is an explicit caller selection — including + // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a + // specific version into its method name, so a disagreement is always a contradiction; + // the silently-overridden case is precisely what we are trying to prevent. + string existingValue = existingStart.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); } return options; @@ -358,12 +380,14 @@ public static Task Callinvoiceworkflow_v2Async( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) + if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) { - if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + string existingValue = existingSub.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); } return options; @@ -451,12 +475,18 @@ public static Task CallInvoiceWorkflowAsync( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: { Length: > 0 } existingVersion }) + if (options?.Version is TaskVersion existingStart) { - if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + // Any non-null Version on the options is an explicit caller selection — including + // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a + // specific version into its method name, so a disagreement is always a contradiction; + // the silently-overridden case is precisely what we are trying to prevent. + string existingValue = existingStart.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); } return options; @@ -478,12 +508,14 @@ public static Task CallInvoiceWorkflowAsync( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) + if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) { - if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + string existingValue = existingSub.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); } return options; @@ -587,12 +619,18 @@ public static Task CallInvoiceWorkflowAsync( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is { Version: { Length: > 0 } existingVersion }) + if (options?.Version is TaskVersion existingStart) { - if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase)) + // Any non-null Version on the options is an explicit caller selection — including + // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a + // specific version into its method name, so a disagreement is always a contradiction; + // the silently-overridden case is precisely what we are trying to prevent. + string existingValue = existingStart.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); + $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); } return options; @@ -614,12 +652,14 @@ public static Task CallInvoiceWorkflowAsync( static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } }) + if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) { - if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase)) + string existingValue = existingSub.Version ?? string.Empty; + if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) { + string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); + $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); } return options; diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs index bb3598de0..52351f03b 100644 --- a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs @@ -181,10 +181,11 @@ public async Task ClassBasedVersionedActivity_UserSuppliedReservedTagDoesNotDisa } /// - /// Verifies the in-proc task-scheduled serializer preserves activity tags. + /// Verifies the in-proc task-scheduled serializer preserves the version-source tag end-to-end so the + /// worker can distinguish explicit-version dispatch from inherited-version dispatch. /// [Fact] - public void TaskScheduledEventSerialization_PreservesExplicitVersionMarker() + public void TaskScheduledEventSerialization_PreservesVersionSourceTag() { TaskScheduledEvent scheduledEvent = new( eventId: 7, @@ -194,7 +195,7 @@ public void TaskScheduledEventSerialization_PreservesExplicitVersionMarker() { Tags = new Dictionary { - [ExplicitVersionTagName] = bool.TrueString, + [ActivityVersioning.VersionSourceTagName] = ActivityVersioning.ExplicitSource, }, }; @@ -203,9 +204,9 @@ public void TaskScheduledEventSerialization_PreservesExplicitVersionMarker() Assert.Equal("VersionedActivityOverrideActivity", proto.TaskScheduled.Name); Assert.Equal("v1", proto.TaskScheduled.Version); Assert.True( - proto.TaskScheduled.Tags.TryGetValue(ExplicitVersionTagName, out string? tagValue), - $"Expected tag '{ExplicitVersionTagName}' to be present."); - Assert.Equal(bool.TrueString, tagValue); + proto.TaskScheduled.Tags.TryGetValue(ActivityVersioning.VersionSourceTagName, out string? tagValue), + $"Expected tag '{ActivityVersioning.VersionSourceTagName}' to be present."); + Assert.Equal(ActivityVersioning.ExplicitSource, tagValue); } /// diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs index e051e8ff6..a18bbaf8d 100644 --- a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Collections.Generic; +using Microsoft.DurableTask.Worker; namespace Microsoft.DurableTask.Grpc.Tests; @@ -10,8 +11,6 @@ namespace Microsoft.DurableTask.Grpc.Tests; /// public static class VersionedClassSyntaxTestOrchestration { - public const string ExplicitVersionTagName = "microsoft.durabletask.activity.explicit-version"; - /// /// Version 1 of the explicit version routing orchestration. /// @@ -131,7 +130,8 @@ public override Task RunAsync(TaskActivityContext context, int input) } /// - /// Version 2 of the orchestration that passes the reserved explicit-version tag in user-supplied task options. + /// Version 2 of the orchestration that attempts to spoof the version-source tag in user-supplied + /// task options. The SDK must strip the reserved key and re-stamp it based on the actual options. /// [DurableTask("SpoofedActivityVersionTagFallbackOrchestration")] [DurableTaskVersion("v2")] @@ -144,7 +144,11 @@ public override Task RunAsync(TaskOrchestrationContext context, int inpu input, new TaskOptions(tags: new Dictionary { - [ExplicitVersionTagName] = bool.FalseString, + // Caller tries to lie that the activity version is "explicit" so the worker would + // refuse the unversioned-fallback. The SDK must strip this reserved key on the + // outbound path and re-stamp the source as "inherited" (since no ActivityOptions + // explicit version was set), preserving the inherited-fallback path. + [ActivityVersioning.VersionSourceTagName] = ActivityVersioning.ExplicitSource, })); } From d11db378c300589dbc62074875f24c0dface0df1 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 09:06:43 -0700 Subject: [PATCH 41/52] Phase 1: collapse OrchestratorVersionKey and ActivityVersionKey into TaskVersionKey The two structs were byte-identical 65-LOC copies. Replace with a single internal TaskVersionKey used for both orchestrator and activity registry keys. No behavioral change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/ActivityVersionKey.cs | 65 ------------------- .../DurableTaskRegistry.Orchestrators.cs | 2 +- src/Abstractions/DurableTaskRegistry.cs | 10 +-- ...stratorVersionKey.cs => TaskVersionKey.cs} | 28 ++++---- src/Worker/Core/DurableTaskFactory.cs | 16 ++--- .../Core/DurableTaskRegistryExtensions.cs | 4 +- 6 files changed, 30 insertions(+), 95 deletions(-) delete mode 100644 src/Abstractions/ActivityVersionKey.cs rename src/Abstractions/{OrchestratorVersionKey.cs => TaskVersionKey.cs} (60%) diff --git a/src/Abstractions/ActivityVersionKey.cs b/src/Abstractions/ActivityVersionKey.cs deleted file mode 100644 index 072f7875e..000000000 --- a/src/Abstractions/ActivityVersionKey.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask; - -/// -/// Represents the logical name and version of a registered activity. -/// -internal readonly struct ActivityVersionKey : IEquatable -{ - /// - /// Initializes a new instance of the struct. - /// - /// The activity name. - /// The activity version. - public ActivityVersionKey(TaskName name, TaskVersion version) - : this(name.Name, version.Version) - { - } - - /// - /// Initializes a new instance of the struct. - /// - /// The activity name. - /// The activity version. - public ActivityVersionKey(string name, string? version) - { - this.Name = Check.NotNullOrEmpty(name, nameof(name)); - this.Version = version ?? string.Empty; - } - - /// - /// Gets the logical activity name. - /// - public string Name { get; } - - /// - /// Gets the activity version. - /// - public string Version { get; } - - /// - /// Determines whether the specified key is equal to the current key. - /// - /// The key to compare with the current key. - /// true if the keys are equal; otherwise false. - public bool Equals(ActivityVersionKey other) - { - return string.Equals(this.Name, other.Name, StringComparison.OrdinalIgnoreCase) - && string.Equals(this.Version, other.Version, StringComparison.OrdinalIgnoreCase); - } - - /// - public override bool Equals(object? obj) => obj is ActivityVersionKey other && this.Equals(other); - - /// - public override int GetHashCode() - { - unchecked - { - return (StringComparer.OrdinalIgnoreCase.GetHashCode(this.Name) * 397) - ^ StringComparer.OrdinalIgnoreCase.GetHashCode(this.Version); - } - } -} diff --git a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs index 80543040a..3b539afb1 100644 --- a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs +++ b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs @@ -55,7 +55,7 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, TaskVersion version, F Check.NotDefault(name); Check.NotNull(factory); - OrchestratorVersionKey key = new(name, version); + TaskVersionKey key = new(name, version); if (this.Orchestrators.ContainsKey(key)) { throw new ArgumentException( diff --git a/src/Abstractions/DurableTaskRegistry.cs b/src/Abstractions/DurableTaskRegistry.cs index 7937618bb..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. @@ -80,7 +80,7 @@ DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func -/// Represents the logical name and version of a registered orchestrator. +/// Represents the logical name and version of a registered orchestrator or activity. /// -internal readonly struct OrchestratorVersionKey : IEquatable +internal readonly struct TaskVersionKey : IEquatable { /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the struct. /// - /// The orchestrator name. - /// The orchestrator version. - public OrchestratorVersionKey(TaskName name, TaskVersion version) + /// The task name. + /// The task version. + public TaskVersionKey(TaskName name, TaskVersion version) : this(name.Name, version.Version) { } /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the struct. /// - /// The orchestrator name. - /// The orchestrator version. - public OrchestratorVersionKey(string name, string? version) + /// 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 orchestrator name. + /// Gets the logical task name. /// public string Name { get; } /// - /// Gets the orchestrator version. + /// Gets the task version. /// public string Version { get; } @@ -44,14 +44,14 @@ public OrchestratorVersionKey(string name, string? version) /// /// The key to compare with the current key. /// true if the keys are equal; otherwise false. - public bool Equals(OrchestratorVersionKey other) + 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 OrchestratorVersionKey other && this.Equals(other); + public override bool Equals(object? obj) => obj is TaskVersionKey other && this.Equals(other); /// public override int GetHashCode() diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 786aa332f..b45f66bbe 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -11,8 +11,8 @@ namespace Microsoft.DurableTask.Worker; /// sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedActivityFactory, IVersionedOrchestratorFactory { - readonly IDictionary> activities; - readonly IDictionary> orchestrators; + readonly IDictionary> activities; + readonly IDictionary> orchestrators; readonly IDictionary> entities; readonly HashSet versionedOrchestratorNames; readonly HashSet versionedActivityNames; @@ -24,8 +24,8 @@ sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedActivityFactor /// The orchestrator factories. /// The entity factories. internal DurableTaskFactory( - IDictionary> activities, - IDictionary> orchestrators, + IDictionary> activities, + IDictionary> orchestrators, IDictionary> entities) { this.activities = Check.NotNull(activities); @@ -57,7 +57,7 @@ public bool TryCreateActivity( [NotNullWhen(true)] out ITaskActivity? activity) { Check.NotNull(serviceProvider); - ActivityVersionKey key = new(name, version); + TaskVersionKey key = new(name, version); if (this.activities.TryGetValue(key, out Func? factory)) { activity = factory.Invoke(serviceProvider); @@ -67,7 +67,7 @@ public bool TryCreateActivity( if (allowVersionFallback && !string.IsNullOrWhiteSpace(version.Version) && !this.versionedActivityNames.Contains(name.Name) - && this.activities.TryGetValue(new ActivityVersionKey(name, default(TaskVersion)), out factory)) + && this.activities.TryGetValue(new TaskVersionKey(name, default(TaskVersion)), out factory)) { activity = factory.Invoke(serviceProvider); return true; @@ -90,7 +90,7 @@ public bool TryCreateOrchestrator( [NotNullWhen(true)] out ITaskOrchestrator? orchestrator) { Check.NotNull(serviceProvider); - OrchestratorVersionKey key = new(name, version); + TaskVersionKey key = new(name, version); if (this.orchestrators.TryGetValue(key, out Func? factory)) { orchestrator = factory.Invoke(serviceProvider); @@ -103,7 +103,7 @@ public bool TryCreateOrchestrator( // catch-all registration the caller did not ask for. if (!string.IsNullOrWhiteSpace(version.Version) && !this.versionedOrchestratorNames.Contains(name.Name) - && this.orchestrators.TryGetValue(new OrchestratorVersionKey(name, default(TaskVersion)), out factory)) + && this.orchestrators.TryGetValue(new TaskVersionKey(name, default(TaskVersion)), out factory)) { orchestrator = factory.Invoke(serviceProvider); return true; diff --git a/src/Worker/Core/DurableTaskRegistryExtensions.cs b/src/Worker/Core/DurableTaskRegistryExtensions.cs index dbac98906..f0b27b5ca 100644 --- a/src/Worker/Core/DurableTaskRegistryExtensions.cs +++ b/src/Worker/Core/DurableTaskRegistryExtensions.cs @@ -28,7 +28,7 @@ public static IDurableTaskFactory BuildFactory(this DurableTaskRegistry registry internal static bool HasAnyVersionedRegistration(this DurableTaskRegistry registry) { Check.NotNull(registry); - foreach (OrchestratorVersionKey key in registry.Orchestrators.Keys) + foreach (TaskVersionKey key in registry.Orchestrators.Keys) { if (!string.IsNullOrWhiteSpace(key.Version)) { @@ -36,7 +36,7 @@ internal static bool HasAnyVersionedRegistration(this DurableTaskRegistry regist } } - foreach (ActivityVersionKey key in registry.Activities.Keys) + foreach (TaskVersionKey key in registry.Activities.Keys) { if (!string.IsNullOrWhiteSpace(key.Version)) { From 9e148ba6103f81e89e9b29c174e0f6dc2623f6a0 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 09:08:40 -0700 Subject: [PATCH 42/52] Phase 2: consolidate IVersionedActivityFactory and IVersionedOrchestratorFactory Merge the two internal interfaces into a single IVersionedTaskFactory. DurableTaskFactory implements one fewer interface, and the processor performs one runtime type-check per dispatch instead of two. The public IDurableTaskFactory contracts are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskFactory.cs | 2 +- .../Core/IVersionedOrchestratorFactory.cs | 26 ------------------- ...ityFactory.cs => IVersionedTaskFactory.cs} | 20 +++++++++++--- .../Grpc/GrpcDurableTaskWorker.Processor.cs | 6 ++--- ...rableTaskFactoryActivityVersioningTests.cs | 10 +++---- .../DurableTaskFactoryVersioningTests.cs | 12 ++++----- 6 files changed, 32 insertions(+), 44 deletions(-) delete mode 100644 src/Worker/Core/IVersionedOrchestratorFactory.cs rename src/Worker/Core/{IVersionedActivityFactory.cs => IVersionedTaskFactory.cs} (54%) diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index b45f66bbe..07a90980d 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -9,7 +9,7 @@ namespace Microsoft.DurableTask.Worker; /// /// A factory for creating orchestrators and activities. /// -sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedActivityFactory, IVersionedOrchestratorFactory +sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedTaskFactory { readonly IDictionary> activities; readonly IDictionary> orchestrators; diff --git a/src/Worker/Core/IVersionedOrchestratorFactory.cs b/src/Worker/Core/IVersionedOrchestratorFactory.cs deleted file mode 100644 index 625f03e56..000000000 --- a/src/Worker/Core/IVersionedOrchestratorFactory.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.DurableTask.Worker; - -/// -/// Creates orchestrator instances by exact logical name and version. -/// -internal interface IVersionedOrchestratorFactory -{ - /// - /// Tries to create an orchestrator that matches the provided logical name and version. - /// - /// The orchestrator name. - /// The orchestrator version. - /// The service provider. - /// The created orchestrator, if found. - /// true if a matching orchestrator was created; otherwise false. - bool TryCreateOrchestrator( - TaskName name, - TaskVersion version, - IServiceProvider serviceProvider, - [NotNullWhen(true)] out ITaskOrchestrator? orchestrator); -} diff --git a/src/Worker/Core/IVersionedActivityFactory.cs b/src/Worker/Core/IVersionedTaskFactory.cs similarity index 54% rename from src/Worker/Core/IVersionedActivityFactory.cs rename to src/Worker/Core/IVersionedTaskFactory.cs index 45f31109b..b97220871 100644 --- a/src/Worker/Core/IVersionedActivityFactory.cs +++ b/src/Worker/Core/IVersionedTaskFactory.cs @@ -6,11 +6,25 @@ namespace Microsoft.DurableTask.Worker; /// -/// Creates activity instances by logical name and requested version. -/// Callers can choose whether an unversioned registration may satisfy a versioned request when no exact match exists. +/// Creates orchestrator and activity instances by logical name and requested version. +/// Implemented by the default factory when the registry contains versioned registrations. /// -internal interface IVersionedActivityFactory +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. /// diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index db09a05a8..6704db4e0 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -721,7 +721,7 @@ await this.ExecuteWithRetryAsync( runtimeState.NewEvents.Count); await using AsyncServiceScope scope = this.worker.services.CreateAsyncScope(); - bool found = this.worker.Factory is IVersionedOrchestratorFactory versionedFactory + bool found = this.worker.Factory is IVersionedTaskFactory versionedFactory ? versionedFactory.TryCreateOrchestrator( name, requestedVersion, @@ -901,7 +901,7 @@ async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, : new TaskVersion(request.Version); ITaskActivity? activity; bool found; - if (this.worker.Factory is IVersionedActivityFactory versionedFactory) + if (this.worker.Factory is IVersionedTaskFactory versionedFactory) { // Read the version-source tag from the request to decide between strict and inherited // dispatch. Fail-closed semantics: missing tag on a versioned request is treated as @@ -938,7 +938,7 @@ async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, failureDetails = new P.TaskFailureDetails { ErrorType = "ActivityTaskNotFound", - ErrorMessage = string.IsNullOrEmpty(versionText) || this.worker.Factory is not IVersionedActivityFactory + ErrorMessage = string.IsNullOrEmpty(versionText) || this.worker.Factory is not IVersionedTaskFactory ? $"No activity task named '{name}' was found." : $"No activity task named '{name}' with version '{versionText}' was found.", IsNonRetriable = true, diff --git a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs index 2fc4dc058..1823274af 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs @@ -15,7 +15,7 @@ public void TryCreateActivity_WithMatchingVersion_ReturnsMatchingImplementation( IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), @@ -36,7 +36,7 @@ public void TryCreateActivity_WithoutMatchingVersion_ReturnsFalse() IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), @@ -57,7 +57,7 @@ public void TryCreateActivity_WithRequestedVersion_UsesUnversionedRegistrationWh IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), @@ -80,7 +80,7 @@ public void TryCreateActivity_WithMixedRegistrations_PrefersExactVersionMatch() IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( new TaskName("InvoiceActivity"), new TaskVersion("v1"), Mock.Of(), @@ -120,7 +120,7 @@ public void TryCreateActivity_WithRequestedVersion_DoesNotUseUnversionedRegistra IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedActivityFactory)factory).TryCreateActivity( + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), diff --git a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs index 20cc9ffa4..3258ba4f7 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -15,7 +15,7 @@ public void TryCreateOrchestrator_WithMatchingVersion_ReturnsMatchingImplementat IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( new TaskName("InvoiceWorkflow"), new TaskVersion("v2"), Mock.Of(), @@ -35,7 +35,7 @@ public void TryCreateOrchestrator_WithoutMatchingVersion_ReturnsFalse() IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( new TaskName("InvoiceWorkflow"), new TaskVersion("v2"), Mock.Of(), @@ -55,7 +55,7 @@ public void TryCreateOrchestrator_WithRequestedVersion_UsesUnversionedRegistrati IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( new TaskName("InvoiceWorkflow"), new TaskVersion("v2"), Mock.Of(), @@ -77,7 +77,7 @@ public void TryCreateOrchestrator_WithMixedRegistrations_PrefersExactVersionMatc IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( new TaskName("InvoiceWorkflow"), new TaskVersion("v1"), Mock.Of(), @@ -101,7 +101,7 @@ public void TryCreateOrchestrator_WithMixedRegistrations_DoesNotFallBackForUnkno IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( new TaskName("InvoiceWorkflow"), new TaskVersion("v3"), Mock.Of(), @@ -123,7 +123,7 @@ public void TryCreateOrchestrator_WithOnlyUnversionedRegistration_FallsBackForVe IDurableTaskFactory factory = registry.BuildFactory(); // Act - bool found = ((IVersionedOrchestratorFactory)factory).TryCreateOrchestrator( + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( new TaskName("InvoiceWorkflow"), new TaskVersion("v1"), Mock.Of(), From 89a6e40bc2803cadbb11ff722ef3d79bd4fab13d Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 09:16:03 -0700 Subject: [PATCH 43/52] Phase 3: drop version-source tag mechanism and unify activity dispatch Removes the microsoft.durabletask.activity.version-source tag stamping and worker-side reading. Activities now use the same dispatch rule as orchestrators: exact match on (name, version), with unversioned fallback allowed only when the name has no versioned registration. This eliminates: - src/Worker/Core/ActivityVersioning.cs (tag-key constants) - The reserved-tag stripping in TaskOrchestrationContextWrapper - The fail-closed tag-missing branch in the activity dispatch path - The allowVersionFallback parameter on IVersionedTaskFactory.TryCreateActivity Behavioral note: the previous design treated explicit ActivityOptions.Version as a strict request that never fell back to an unversioned registration. The new rule is symmetric with the orchestrator rule and depends only on whether the name has any versioned registration. The integration test that covered the now-removed strict behavior is dropped along with its supporting orchestration and activity classes. The proto field ActivityRequest.tags is left in place since it lives in the durabletask-protobuf repository; this commit just stops using it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/ActivityVersioning.cs | 39 -------- src/Worker/Core/DurableTaskFactory.cs | 10 +- src/Worker/Core/IVersionedTaskFactory.cs | 5 - .../Shims/TaskOrchestrationContextWrapper.cs | 67 +++---------- .../Grpc/GrpcDurableTaskWorker.Processor.cs | 11 --- .../VersionedClassSyntaxIntegrationTests.cs | 95 ------------------- .../VersionedClassSyntaxTestOrchestration.cs | 63 ------------ ...rableTaskFactoryActivityVersioningTests.cs | 12 +-- .../TaskOrchestrationContextWrapperTests.cs | 49 +--------- 9 files changed, 30 insertions(+), 321 deletions(-) delete mode 100644 src/Worker/Core/ActivityVersioning.cs diff --git a/src/Worker/Core/ActivityVersioning.cs b/src/Worker/Core/ActivityVersioning.cs deleted file mode 100644 index c087252e0..000000000 --- a/src/Worker/Core/ActivityVersioning.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Worker; - -/// -/// Internal helpers for preserving activity version-selection semantics across worker dispatch. -/// -static class ActivityVersioning -{ - /// - /// Internal tag stamped on scheduled activity events to communicate whether the requested activity version - /// is an explicit caller-supplied selection or an inherited orchestration-instance version. The worker reads - /// this tag to choose between strict dispatch (no fallback) and inherited dispatch (fallback to the - /// unversioned registration is allowed). When the tag is missing on a versioned request, the worker fails - /// closed (treats it as ) so a sidecar that drops tags cannot silently degrade - /// strict-explicit semantics to inherited-fallback. - /// - internal const string VersionSourceTagName = "microsoft.durabletask.activity.version-source"; - - /// - /// Tag value indicating the caller explicitly chose this activity version via . - /// Strict dispatch — no unversioned fallback. - /// - internal const string ExplicitSource = "explicit"; - - /// - /// Tag value indicating the activity inherited its version from the orchestration instance. Inherited - /// dispatch — fallback to the unversioned registration is allowed for backward compatibility. - /// - internal const string InheritedSource = "inherited"; - - /// - /// All reserved version-source tag keys. Stripped from caller-supplied to - /// prevent spoofing of the dispatch contract. - /// - internal static readonly string[] ReservedTagKeys = { VersionSourceTagName }; -} - diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 07a90980d..fcc97800f 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -53,7 +53,6 @@ public bool TryCreateActivity( TaskName name, TaskVersion version, IServiceProvider serviceProvider, - bool allowVersionFallback, [NotNullWhen(true)] out ITaskActivity? activity) { Check.NotNull(serviceProvider); @@ -64,8 +63,11 @@ public bool TryCreateActivity( return true; } - if (allowVersionFallback - && !string.IsNullOrWhiteSpace(version.Version) + // 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)) { @@ -80,7 +82,7 @@ public bool TryCreateActivity( /// public bool TryCreateActivity( TaskName name, IServiceProvider serviceProvider, [NotNullWhen(true)] out ITaskActivity? activity) - => this.TryCreateActivity(name, default(TaskVersion), serviceProvider, allowVersionFallback: false, out activity); + => this.TryCreateActivity(name, default(TaskVersion), serviceProvider, out activity); /// public bool TryCreateOrchestrator( diff --git a/src/Worker/Core/IVersionedTaskFactory.cs b/src/Worker/Core/IVersionedTaskFactory.cs index b97220871..51e721aeb 100644 --- a/src/Worker/Core/IVersionedTaskFactory.cs +++ b/src/Worker/Core/IVersionedTaskFactory.cs @@ -31,16 +31,11 @@ bool TryCreateOrchestrator( /// The activity name. /// The activity version. /// The service provider. - /// - /// true to allow an unversioned registration to satisfy a versioned request when no exact match exists; - /// otherwise, false. - /// /// The created activity, if found. /// true if a matching activity was created; otherwise false. bool TryCreateActivity( TaskName name, TaskVersion version, IServiceProvider serviceProvider, - bool allowVersionFallback, [NotNullWhen(true)] out ITaskActivity? activity); } diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index ac9faa5a9..84554d4ac 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -129,62 +129,21 @@ public override async Task CallActivityAsync( object? input = null, TaskOptions? options = null) { - // Returns (effective version string, whether the caller-supplied an explicit version, whether the - // call should be tagged at all). The "tag the call" decision is separate from "explicit": even a - // pure inherited call needs a tag so the worker can fail-closed on a missing tag for a versioned - // request. - static (string RequestedVersion, bool ExplicitVersionRequested, bool StampVersionTag) GetRequestedActivityVersion( - TaskOptions? taskOptions, - string inheritedVersion) + // Returns the version to schedule the activity with. If the caller passed ActivityOptions.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, so the SDK no longer needs to communicate "explicit vs inherited" + // separately. + static string GetRequestedActivityVersion(TaskOptions? taskOptions, string inheritedVersion) { if (taskOptions is ActivityOptions activityOptions && activityOptions.Version is TaskVersion explicitVersion) { - // Any non-null ActivityOptions.Version — including an explicit-unversioned request - // (TaskVersion.Unversioned / default) — is treated as an explicit selection. Strict - // dispatch will be used. - return (explicitVersion.Version ?? string.Empty, true, true); + return explicitVersion.Version ?? string.Empty; } - return (inheritedVersion, false, !string.IsNullOrWhiteSpace(inheritedVersion)); - } - - static IDictionary GetActivityTags( - TaskOptions? taskOptions, - bool explicitVersionRequested, - bool stampVersionTag) - { - Dictionary tags = new(StringComparer.Ordinal); - - if (taskOptions?.Tags is not null) - { - foreach ((string key, string value) in taskOptions.Tags) - { - bool isReserved = false; - foreach (string reserved in ActivityVersioning.ReservedTagKeys) - { - if (string.Equals(key, reserved, StringComparison.Ordinal)) - { - isReserved = true; - break; - } - } - - if (!isReserved) - { - tags[key] = value; - } - } - } - - if (stampVersionTag) - { - tags[ActivityVersioning.VersionSourceTagName] = explicitVersionRequested - ? ActivityVersioning.ExplicitSource - : ActivityVersioning.InheritedSource; - } - - return tags; + return inheritedVersion; } // Since the input parameter takes any object, it's possible that callers may accidentally provide a @@ -201,9 +160,11 @@ static IDictionary GetActivityTags( try { - (string requestedVersion, bool explicitVersionRequested, bool stampVersionTag) = - GetRequestedActivityVersion(options, this.innerContext.Version); - IDictionary tags = GetActivityTags(options, explicitVersionRequested, stampVersionTag); + string requestedVersion = GetRequestedActivityVersion(options, this.innerContext.Version); + + // ScheduleTaskOptions.Builder.WithTags requires a non-null dictionary, so substitute an empty one + // when the caller did not supply tags. + IDictionary tags = options?.Tags ?? new Dictionary(StringComparer.Ordinal); // TODO: Cancellation (https://github.com/microsoft/durabletask-dotnet/issues/7) #pragma warning disable 0618 diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index 6704db4e0..75085e578 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -903,21 +903,10 @@ async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, bool found; if (this.worker.Factory is IVersionedTaskFactory versionedFactory) { - // Read the version-source tag from the request to decide between strict and inherited - // dispatch. Fail-closed semantics: missing tag on a versioned request is treated as - // explicit (no fallback), so a sidecar that drops tags cannot silently downgrade - // strict-explicit semantics to inherited-with-fallback. - bool tagPresent = request.Tags.TryGetValue( - ActivityVersioning.VersionSourceTagName, out string? tagValue); - bool tagSaysInherited = tagPresent - && string.Equals(tagValue, ActivityVersioning.InheritedSource, StringComparison.Ordinal); - bool allowVersionFallback = tagSaysInherited; - found = versionedFactory.TryCreateActivity( name, requestedVersion, scope.ServiceProvider, - allowVersionFallback, out activity); } else diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs index 52351f03b..a960742b1 100644 --- a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs @@ -84,42 +84,6 @@ public async Task ClassBasedVersionedActivity_ExplicitActivityVersionOverridesOr Assert.Equal("activity-v1:5", metadata.ReadOutputAs()); } - /// - /// Verifies explicit activity version selection does not fall back to an unversioned registration. - /// - [Fact] - public async Task ClassBasedVersionedActivity_ExplicitActivityVersionDoesNotFallBackToUnversionedRegistration() - { - await using HostTestLifetime server = await this.StartWorkerAsync(b => - { - b.AddTasks(tasks => - { - tasks.AddOrchestrator(); - tasks.AddActivity(); - }); - }); - - string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( - "ExplicitActivityVersionNoFallbackOrchestration", - input: 5, - new StartOrchestrationOptions - { - Version = new TaskVersion("v2"), - }); - OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( - instanceId, getInputsAndOutputs: true, this.TimeoutToken); - - Assert.NotNull(metadata); - Assert.Equal(OrchestrationRuntimeStatus.Failed, metadata.RuntimeStatus); - Assert.NotNull(metadata.FailureDetails); - Assert.Equal(typeof(TaskFailedException).FullName, metadata.FailureDetails.ErrorType); - Assert.NotNull(metadata.FailureDetails.InnerFailure); - Assert.Equal("ActivityTaskNotFound", metadata.FailureDetails.InnerFailure.ErrorType); - Assert.Contains( - "No activity task named 'ExplicitActivityVersionNoFallbackActivity' with version 'v1' was found.", - metadata.FailureDetails.InnerFailure.ErrorMessage); - } - /// /// Verifies inherited orchestration-version activity routing still falls back to an unversioned registration. /// @@ -150,65 +114,6 @@ public async Task ClassBasedVersionedActivity_InheritedVersionFallsBackToUnversi Assert.Equal("activity-unversioned:5", metadata.ReadOutputAs()); } - /// - /// Verifies user-supplied tags cannot spoof the internal explicit-version marker. - /// - [Fact] - public async Task ClassBasedVersionedActivity_UserSuppliedReservedTagDoesNotDisableInheritedFallback() - { - await using HostTestLifetime server = await this.StartWorkerAsync(b => - { - b.AddTasks(tasks => - { - tasks.AddOrchestrator(); - tasks.AddActivity(); - }); - }); - - string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( - "SpoofedActivityVersionTagFallbackOrchestration", - input: 5, - new StartOrchestrationOptions - { - Version = new TaskVersion("v2"), - }); - OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( - instanceId, getInputsAndOutputs: true, this.TimeoutToken); - - Assert.NotNull(metadata); - Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); - Assert.Equal("activity-unversioned:5", metadata.ReadOutputAs()); - } - - /// - /// Verifies the in-proc task-scheduled serializer preserves the version-source tag end-to-end so the - /// worker can distinguish explicit-version dispatch from inherited-version dispatch. - /// - [Fact] - public void TaskScheduledEventSerialization_PreservesVersionSourceTag() - { - TaskScheduledEvent scheduledEvent = new( - eventId: 7, - name: "VersionedActivityOverrideActivity", - version: "v1", - input: "5") - { - Tags = new Dictionary - { - [ActivityVersioning.VersionSourceTagName] = ActivityVersioning.ExplicitSource, - }, - }; - - var proto = ProtobufUtils.ToHistoryEventProto(scheduledEvent); - - Assert.Equal("VersionedActivityOverrideActivity", proto.TaskScheduled.Name); - Assert.Equal("v1", proto.TaskScheduled.Version); - Assert.True( - proto.TaskScheduled.Tags.TryGetValue(ActivityVersioning.VersionSourceTagName, out string? tagValue), - $"Expected tag '{ActivityVersioning.VersionSourceTagName}' to be present."); - Assert.Equal(ActivityVersioning.ExplicitSource, tagValue); - } - /// /// Verifies starting without a version fails when only versioned handlers are registered. /// diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs index a18bbaf8d..afebca234 100644 --- a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs @@ -77,35 +77,6 @@ public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult($"activity-v2:{input}"); } - /// - /// Version 2 of the orchestration that explicitly requests a missing activity version. - /// - [DurableTask("ExplicitActivityVersionNoFallbackOrchestration")] - [DurableTaskVersion("v2")] - public sealed class ExplicitActivityVersionNoFallbackOrchestrationV2 : TaskOrchestrator - { - /// - public override Task RunAsync(TaskOrchestrationContext context, int input) - => context.CallActivityAsync( - "ExplicitActivityVersionNoFallbackActivity", - input, - new ActivityOptions - { - Version = "v1", - }); - } - - /// - /// Unversioned activity used to verify explicit version requests do not silently fall back. - /// - [DurableTask("ExplicitActivityVersionNoFallbackActivity")] - public sealed class UnversionedActivityVersionNoFallbackActivity : TaskActivity - { - /// - public override Task RunAsync(TaskActivityContext context, int input) - => Task.FromResult($"activity-unversioned:{input}"); - } - /// /// Version 2 of the orchestration that inherits its version when calling an unversioned activity. /// @@ -129,40 +100,6 @@ public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult($"activity-unversioned:{input}"); } - /// - /// Version 2 of the orchestration that attempts to spoof the version-source tag in user-supplied - /// task options. The SDK must strip the reserved key and re-stamp it based on the actual options. - /// - [DurableTask("SpoofedActivityVersionTagFallbackOrchestration")] - [DurableTaskVersion("v2")] - public sealed class SpoofedActivityVersionTagFallbackOrchestrationV2 : TaskOrchestrator - { - /// - public override Task RunAsync(TaskOrchestrationContext context, int input) - => context.CallActivityAsync( - "SpoofedActivityVersionTagFallbackActivity", - input, - new TaskOptions(tags: new Dictionary - { - // Caller tries to lie that the activity version is "explicit" so the worker would - // refuse the unversioned-fallback. The SDK must strip this reserved key on the - // outbound path and re-stamp the source as "inherited" (since no ActivityOptions - // explicit version was set), preserving the inherited-fallback path. - [ActivityVersioning.VersionSourceTagName] = ActivityVersioning.ExplicitSource, - })); - } - - /// - /// Unversioned activity used to verify user-supplied reserved tags do not disable compatibility fallback behavior. - /// - [DurableTask("SpoofedActivityVersionTagFallbackActivity")] - public sealed class UnversionedSpoofedActivityVersionTagFallbackActivity : TaskActivity - { - /// - public override Task RunAsync(TaskActivityContext context, int input) - => Task.FromResult($"activity-unversioned:{input}"); - } - /// /// Version 1 of the continue-as-new orchestration. /// diff --git a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs index 1823274af..e58be36fe 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs @@ -19,7 +19,6 @@ public void TryCreateActivity_WithMatchingVersion_ReturnsMatchingImplementation( new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), - allowVersionFallback: true, out ITaskActivity? activity); // Assert @@ -40,7 +39,6 @@ public void TryCreateActivity_WithoutMatchingVersion_ReturnsFalse() new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), - allowVersionFallback: true, out ITaskActivity? activity); // Assert @@ -61,7 +59,6 @@ public void TryCreateActivity_WithRequestedVersion_UsesUnversionedRegistrationWh new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), - allowVersionFallback: true, out ITaskActivity? activity); // Assert @@ -84,7 +81,6 @@ public void TryCreateActivity_WithMixedRegistrations_PrefersExactVersionMatch() new TaskName("InvoiceActivity"), new TaskVersion("v1"), Mock.Of(), - allowVersionFallback: true, out ITaskActivity? activity); // Assert @@ -112,10 +108,13 @@ public void PublicTryCreateActivity_UsesUnversionedRegistrationOnly() } [Fact] - public void TryCreateActivity_WithRequestedVersion_DoesNotUseUnversionedRegistrationWhenFallbackIsDisallowed() + public void TryCreateActivity_WithMixedRegistrations_DoesNotFallBackToUnversionedWhenAnotherVersionIsRegistered() { - // Arrange + // 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(); @@ -124,7 +123,6 @@ public void TryCreateActivity_WithRequestedVersion_DoesNotUseUnversionedRegistra new TaskName("InvoiceActivity"), new TaskVersion("v2"), Mock.Of(), - allowVersionFallback: false, out ITaskActivity? activity); // Assert diff --git a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs index 006675ed1..cb420835a 100644 --- a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs +++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs @@ -151,9 +151,6 @@ await wrapper.CallActivityAsync( innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v1"); innerContext.LastScheduledTaskInput.Should().Be(123); - GetLastScheduledTaskTags(innerContext).Should().Contain( - ActivityVersioning.VersionSourceTagName, - ActivityVersioning.ExplicitSource); } [Fact] @@ -215,38 +212,30 @@ public async Task CallActivityAsync_PlainTaskOptionsUsesInheritedOrchestrationVe innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); innerContext.LastScheduledTaskInput.Should().Be(123); - // An inherited non-empty version stamps the source as Inherited so the worker can fail-closed - // when a sidecar drops tags rather than silently degrading strict-explicit semantics. - GetLastScheduledTaskTags(innerContext).Should().Contain( - ActivityVersioning.VersionSourceTagName, - ActivityVersioning.InheritedSource); } [Fact] - public async Task CallActivityAsync_UserSuppliedReservedVersionSourceTagIsStripped() + 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 — the user attempts to spoof "explicit" via Tags. The SDK strips the reserved key and - // re-stamps according to the actual options (inherited here). + // Act — caller supplies arbitrary tags; the SDK preserves them verbatim. await wrapper.CallActivityAsync( "TestActivity", 123, new TaskOptions(tags: new Dictionary { - [ActivityVersioning.VersionSourceTagName] = ActivityVersioning.ExplicitSource, + ["caller.tag"] = "caller-value", })); // Assert innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); innerContext.LastScheduledTaskInput.Should().Be(123); - GetLastScheduledTaskTags(innerContext).Should().Contain( - ActivityVersioning.VersionSourceTagName, - ActivityVersioning.InheritedSource); + GetLastScheduledTaskTags(innerContext).Should().Contain("caller.tag", "caller-value"); } [Fact] @@ -264,27 +253,6 @@ public async Task CallActivityAsync_NullOptionsUsesInheritedOrchestrationVersion innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); innerContext.LastScheduledTaskInput.Should().Be(123); - GetLastScheduledTaskTags(innerContext).Should().Contain( - ActivityVersioning.VersionSourceTagName, - ActivityVersioning.InheritedSource); - } - - [Fact] - public async Task CallActivityAsync_NullOptionsAndUnversionedOrchestration_StampsNoTag() - { - // Arrange — orchestration is unversioned (no instance version). No explicit option either. - TrackingOrchestrationContext innerContext = new(); - OrchestrationInvocationContext invocationContext = new("Test", new(), NullLoggerFactory.Instance, null); - TaskOrchestrationContextWrapper wrapper = new(innerContext, invocationContext, "input"); - - // Act - await wrapper.CallActivityAsync("TestActivity", 123); - - // Assert — no version, no tag stamped (matches pre-versioning behavior). - innerContext.LastScheduledTaskName.Should().Be("TestActivity"); - innerContext.LastScheduledTaskVersion.Should().Be(string.Empty); - innerContext.LastScheduledTaskInput.Should().Be(123); - GetLastScheduledTaskTags(innerContext).Should().NotContainKey(ActivityVersioning.VersionSourceTagName); } [Fact] @@ -301,9 +269,6 @@ public async Task CallActivityAsync_NullActivityOptionsVersion_InheritsOrchestra // Assert innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be("v2"); - GetLastScheduledTaskTags(innerContext).Should().Contain( - ActivityVersioning.VersionSourceTagName, - ActivityVersioning.InheritedSource); } [Fact] @@ -320,13 +285,9 @@ await wrapper.CallActivityAsync( 123, new ActivityOptions { Version = TaskVersion.Unversioned }); - // Assert — empty version is sent (the unversioned activity), but the tag identifies it as explicit - // so the worker uses strict dispatch instead of inheriting the orchestration's v2 version. + // Assert — empty version is sent (the unversioned activity), instead of inheriting v2. innerContext.LastScheduledTaskName.Should().Be("TestActivity"); innerContext.LastScheduledTaskVersion.Should().Be(string.Empty); - GetLastScheduledTaskTags(innerContext).Should().Contain( - ActivityVersioning.VersionSourceTagName, - ActivityVersioning.ExplicitSource); } static IReadOnlyDictionary GetLastScheduledTaskTags(TrackingOrchestrationContext innerContext) From 8b4425579ad2b7402b7cc7af7951de2c37c9adcb Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 09:19:18 -0700 Subject: [PATCH 44/52] Phase 4: drop WorkerVersioningPolicy.EnsureNotCombined guard UseVersioning() and multi-version (name, version) registrations are no longer treated as mutually exclusive features. They compose naturally: - UseVersioning's MatchStrategy decides which instance versions a worker accepts off the wire. - The per-task registry decides which implementation handles a surviving work item. Removes: - src/Worker/Core/WorkerVersioningPolicy.cs (the fail-fast guard) - The HasAnyVersionedRegistration extension that only existed to feed the guard - The guard invocation in GrpcDurableTaskWorker's constructor The Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_* test is flipped from asserting InvalidOperationException to asserting the combined configuration constructs successfully. The registryMonitor optional constructor parameter on GrpcDurableTaskWorker is left in place to keep the existing public constructor signature unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Core/DurableTaskRegistryExtensions.cs | 28 ---------- src/Worker/Core/WorkerVersioningPolicy.cs | 54 ------------------- src/Worker/Grpc/GrpcDurableTaskWorker.cs | 4 -- .../Grpc.Tests/GrpcDurableTaskWorkerTests.cs | 14 ++--- 4 files changed, 7 insertions(+), 93 deletions(-) delete mode 100644 src/Worker/Core/WorkerVersioningPolicy.cs diff --git a/src/Worker/Core/DurableTaskRegistryExtensions.cs b/src/Worker/Core/DurableTaskRegistryExtensions.cs index f0b27b5ca..f89288315 100644 --- a/src/Worker/Core/DurableTaskRegistryExtensions.cs +++ b/src/Worker/Core/DurableTaskRegistryExtensions.cs @@ -18,32 +18,4 @@ public static IDurableTaskFactory BuildFactory(this DurableTaskRegistry registry Check.NotNull(registry); return new DurableTaskFactory(registry.Activities, registry.Orchestrators, registry.Entities); } - - /// - /// Returns a value indicating whether any orchestrator or activity in the registry has been - /// registered with an explicit (non-empty) -style version. - /// - /// The registry to inspect. - /// true if any registration carries a non-empty version; otherwise, false. - internal static bool HasAnyVersionedRegistration(this DurableTaskRegistry registry) - { - Check.NotNull(registry); - foreach (TaskVersionKey key in registry.Orchestrators.Keys) - { - if (!string.IsNullOrWhiteSpace(key.Version)) - { - return true; - } - } - - foreach (TaskVersionKey key in registry.Activities.Keys) - { - if (!string.IsNullOrWhiteSpace(key.Version)) - { - return true; - } - } - - return false; - } } diff --git a/src/Worker/Core/WorkerVersioningPolicy.cs b/src/Worker/Core/WorkerVersioningPolicy.cs deleted file mode 100644 index e4fcc76a0..000000000 --- a/src/Worker/Core/WorkerVersioningPolicy.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Worker; - -/// -/// Worker startup checks that all subclasses should perform before they -/// begin processing work items. Centralizing the checks keeps every transport (gRPC, in-proc, future -/// transports) on the same set of guarantees. -/// -internal static class WorkerVersioningPolicy -{ - /// - /// Throws when worker-level is configured - /// alongside per-task [DurableTaskVersion] registrations. The two features both consume the - /// orchestration instance version field; combining them silently masks per-task routing because the - /// worker-level filter rejects versioned work items before per-task dispatch can run. - /// - /// The worker name (for the diagnostic message). - /// The worker options. - /// The registry, or null if the worker did not opt into the check. - /// - /// Thrown when both worker-level and per-task versioning are configured. - /// - public static void EnsureNotCombined( - string workerName, - DurableTaskWorkerOptions workerOptions, - DurableTaskRegistry? registry) - { - Check.NotNull(workerOptions); - if (registry is null) - { - return; - } - - if (workerOptions.Versioning is not DurableTaskWorkerOptions.VersioningOptions versioning - || versioning.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.None) - { - return; - } - - if (!registry.HasAnyVersionedRegistration()) - { - return; - } - - throw new InvalidOperationException( - $"Worker '{workerName}' has both worker-level versioning (UseVersioning with MatchStrategy = '{versioning.MatchStrategy}') " - + $"and per-task [DurableTaskVersion] registrations configured. These features are not designed to be combined: " - + $"worker-level version checks run before per-task dispatch and will reject orchestrations whose instance version " - + $"does not match the worker version, silently masking per-task routing. Pick one. " - + $"See https://aka.ms/durabletask-versioning for guidance."); - } -} diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.cs index 34baecde6..a9cc392d5 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.cs @@ -57,10 +57,6 @@ public GrpcDurableTaskWorker( this.orchestrationFilter = orchestrationFilter; this.ExceptionPropertiesProvider = exceptionPropertiesProvider; this.workItemFilters = workItemFiltersMonitor?.Get(name); - - // Fail fast when worker-level versioning is combined with per-task [DurableTaskVersion] registrations. - // The two features are not designed to coexist (see WorkerVersioningPolicy for details). - WorkerVersioningPolicy.EnsureNotCombined(name, this.workerOptions, registryMonitor?.Get(name)); } /// diff --git a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index 5df9d57d8..0dfb19d82 100644 --- a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs +++ b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs @@ -490,9 +490,12 @@ public async Task ConnectAsync_VeryLargeHelloDeadline_UsesUtcMaxValueDeadline() } [Fact] - public void Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_Throws() + public void Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_DoesNotThrow() { - // Arrange + // 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. DurableTaskRegistry registry = new(); registry.AddOrchestrator(new TaskName("MyOrch"), new TaskVersion("1"), () => Mock.Of()); registry.AddOrchestrator(new TaskName("MyOrch"), new TaskVersion("2"), () => Mock.Of()); @@ -509,11 +512,8 @@ public void Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_Thro // Act Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance, registry); - // Assert — combined configuration fails fast at worker construction. - act.Should().Throw() - .WithMessage("*per-task [DurableTaskVersion]*") - .WithMessage("*worker-level versioning*") - .WithMessage("*Strict*"); + // Assert + act.Should().NotThrow(); } [Fact] From 36869d6d852b097dc120edede947cf77b842341e Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 09:24:38 -0700 Subject: [PATCH 45/52] Phase 5: move TaskVersion? Version onto TaskOptions; drop ActivityOptions record Version was previously declared on both the new ActivityOptions record and on SubOrchestrationOptions. Both moved to the base TaskOptions record alongside Retry and Tags, eliminating the parallel pattern. This removes: - The public ActivityOptions record (added pre-release in this PR) - The redundant Version property on SubOrchestrationOptions and its copy-from-source plumbing - The dual-branch logic that read Version from either ActivityOptions or SubOrchestrationOptions inside the context wrapper Callers now use 'new TaskOptions { Version = ... }' for both activity and sub-orchestration version overrides, with the same dispatch rule. StartOrchestrationOptions retains its own Version property because it is a separate record hierarchy on the client side. Updates the generator emission and the corresponding golden tests so ApplyGeneratedActivityVersion operates on TaskOptions directly instead of the now-removed ActivityOptions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/TaskOptions.cs | 80 ++++-------- src/Abstractions/TaskVersion.cs | 2 +- src/Generators/DurableTaskSourceGenerator.cs | 19 +-- .../Shims/TaskOrchestrationContextWrapper.cs | 10 +- test/Abstractions.Tests/TaskOptionsTests.cs | 118 +++++++----------- .../VersionedActivityTests.cs | 57 +++------ .../VersionedClassSyntaxTestOrchestration.cs | 2 +- .../TaskOrchestrationContextWrapperTests.cs | 20 +-- 8 files changed, 100 insertions(+), 208 deletions(-) diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index b8400b4c0..ff490b30d 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Collections.Immutable; @@ -39,6 +39,7 @@ public TaskOptions(TaskOptions options) Check.NotNull(options); this.Retry = options.Retry; this.Tags = options.Tags; + this.Version = options.Version; } /// @@ -51,6 +52,23 @@ public TaskOptions(TaskOptions options) /// 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 . /// @@ -81,51 +99,6 @@ public TaskOptions(TaskOptions options) public SubOrchestrationOptions WithInstanceId(string instanceId) => new(this, instanceId); } -/// -/// Options that can be used to control the behavior of activity task execution. This derived type can be used to -/// supply extra options for activities. -/// -public record ActivityOptions : TaskOptions -{ - /// - /// Initializes a new instance of the class. - /// - /// The task retry options. - public ActivityOptions(TaskRetryOptions? retry = null) - : base(retry) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The task options to wrap. - public ActivityOptions(TaskOptions options) - : base(options) - { - if (options is ActivityOptions derived) - { - this.Version = derived.Version; - } - } - - /// - /// Initializes a new instance of the class by copying from another instance. - /// - /// The activity options to copy from. - public ActivityOptions(ActivityOptions options) - : base(options) - { - Check.NotNull(options); - this.Version = options.Version; - } - - /// - /// Gets the version to associate with the activity. - /// - public TaskVersion? Version { get; init; } -} - /// /// Options that can be used to control the behavior of orchestrator task execution. This derived type can be used to /// supply extra options for orchestrations. @@ -152,14 +125,9 @@ public SubOrchestrationOptions(TaskOptions options, string? instanceId = null) : base(options) { this.InstanceId = instanceId; - if (options is SubOrchestrationOptions derived) + if (options is SubOrchestrationOptions derived && instanceId is null) { - if (instanceId is null) - { - this.InstanceId = derived.InstanceId; - } - - this.Version = derived.Version; + this.InstanceId = derived.InstanceId; } } @@ -172,18 +140,12 @@ public SubOrchestrationOptions(SubOrchestrationOptions 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; } } /// diff --git a/src/Abstractions/TaskVersion.cs b/src/Abstractions/TaskVersion.cs index 96c635b34..bcd8beabf 100644 --- a/src/Abstractions/TaskVersion.cs +++ b/src/Abstractions/TaskVersion.cs @@ -13,7 +13,7 @@ namespace Microsoft.DurableTask; /// new TaskVersion(string.Empty). /// /// - /// Use this on to explicitly request the unversioned activity + /// 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. /// diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 67402f9af..3e678a24d 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -1015,10 +1015,9 @@ static void AddStandaloneGeneratedVersionHelperMethods( sourceBuilder.AppendLine(@" static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { - if (options is ActivityOptions activityOptions - && activityOptions.Version is TaskVersion explicitVersion) + if (options?.Version is TaskVersion explicitVersion) { - // Any non-null ActivityOptions.Version is an explicit caller selection — including + // Any non-null TaskOptions.Version is an explicit caller selection — including // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- // baked version is always a contradiction, so we throw rather than silently override. string explicitValue = explicitVersion.Version ?? string.Empty; @@ -1026,29 +1025,21 @@ static void AddStandaloneGeneratedVersionHelperMethods( { string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); + $""The generated activity helper targets version '{version}' but TaskOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); } return options; } - if (options is ActivityOptions existingActivityOptions) - { - return new ActivityOptions(existingActivityOptions) - { - Version = version, - }; - } - if (options is null) { - return new ActivityOptions + return new TaskOptions { Version = version, }; } - return new ActivityOptions(options) + return new TaskOptions(options) { Version = version, }; diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index 84554d4ac..b8ad09203 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -129,16 +129,14 @@ public override async Task CallActivityAsync( object? input = null, TaskOptions? options = null) { - // Returns the version to schedule the activity with. If the caller passed ActivityOptions.Version, + // 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, so the SDK no longer needs to communicate "explicit vs inherited" - // separately. + // how the version was selected. static string GetRequestedActivityVersion(TaskOptions? taskOptions, string inheritedVersion) { - if (taskOptions is ActivityOptions activityOptions - && activityOptions.Version is TaskVersion explicitVersion) + if (taskOptions?.Version is TaskVersion explicitVersion) { return explicitVersion.Version ?? string.Empty; } @@ -223,7 +221,7 @@ public override async Task CallSubOrchestratorAsync( => 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; + string version = options?.Version is { } v ? v.Version : defaultVersion; Check.NotEntity(this.invocationContext.Options.EnableEntitySupport, instanceId); // if this orchestration uses entities, first validate that the suborchestration call is allowed in the current context diff --git a/test/Abstractions.Tests/TaskOptionsTests.cs b/test/Abstractions.Tests/TaskOptionsTests.cs index 2a215efbf..96a12b140 100644 --- a/test/Abstractions.Tests/TaskOptionsTests.cs +++ b/test/Abstractions.Tests/TaskOptionsTests.cs @@ -10,19 +10,16 @@ public class TaskOptionsTests [Fact] public void Empty_Ctors_Okay() { - TaskOptions options = new(); - options.Retry.Should().BeNull(); - options.Tags.Should().BeNull(); - - ActivityOptions activityOptions = new(); - activityOptions.Retry.Should().BeNull(); - activityOptions.Tags.Should().BeNull(); - activityOptions.Version.Should().BeNull(); - - SubOrchestrationOptions subOptions = new(); - subOptions.Retry.Should().BeNull(); - subOptions.Tags.Should().BeNull(); - subOptions.InstanceId.Should().BeNull(); + 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(); @@ -175,68 +172,39 @@ public void TaskOptions_CopyConstructor_CopiesAllProperties() copy.Tags.Should().BeSameAs(original.Tags); } - [Fact] - public void ActivityOptions_CopyConstructor_CopiesAllProperties() - { - // Arrange - RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); - TaskRetryOptions retry = new(policy); - Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; - TaskVersion version = new("1.0"); - ActivityOptions original = new(retry) - { - Tags = tags, - Version = version, - }; - - // Act - ActivityOptions copy = new(original); - - // Assert - copy.Retry.Should().Be(original.Retry); - copy.Tags.Should().BeSameAs(original.Tags); - copy.Version.Should().Be(original.Version); - } - - [Fact] - public void ActivityOptions_CopyFromTaskOptions_PreservesRetryAndTagsButLeavesVersionNull() - { - // Arrange - RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); - TaskRetryOptions retry = new(policy); - Dictionary tags = new() { { "key1", "value1" }, { "key2", "value2" } }; - TaskOptions original = new(retry, tags); - - // Act - ActivityOptions copy = new(original); - - // Assert - copy.Retry.Should().Be(original.Retry); - copy.Tags.Should().BeSameAs(original.Tags); - copy.Version.Should().BeNull(); - } - - [Fact] - public void ActivityOptions_CopyFromTaskOptions_CopiesVersionWhenSourceIsActivityOptions() - { - // Arrange - RetryPolicy policy = new(3, TimeSpan.FromSeconds(1)); - TaskRetryOptions retry = new(policy); - Dictionary tags = new() { { "key1", "value1" } }; - TaskVersion version = new("1.0"); - ActivityOptions original = new(retry) - { - Tags = tags, - Version = version, - }; - - // Act - ActivityOptions copy = new(original as TaskOptions); - - // Assert - copy.Retry.Should().Be(original.Retry); - copy.Tags.Should().BeSameAs(original.Tags); - copy.Version.Should().Be(original.Version); + [Fact] + public void TaskOptions_VersionInitializer_PersistsValue() + { + // Arrange + TaskVersion version = new("1.0"); + + // Act + TaskOptions options = new() { Version = version }; + + // Assert + options.Version.Should().Be(version); + } + + [Fact] + 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] diff --git a/test/Generators.Tests/VersionedActivityTests.cs b/test/Generators.Tests/VersionedActivityTests.cs index 8f2f97bd7..fafb15c9d 100644 --- a/test/Generators.Tests/VersionedActivityTests.cs +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -43,10 +43,9 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { - if (options is ActivityOptions activityOptions - && activityOptions.Version is TaskVersion explicitVersion) + if (options?.Version is TaskVersion explicitVersion) { - // Any non-null ActivityOptions.Version is an explicit caller selection — including + // Any non-null TaskOptions.Version is an explicit caller selection — including // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- // baked version is always a contradiction, so we throw rather than silently override. string explicitValue = explicitVersion.Version ?? string.Empty; @@ -54,29 +53,21 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex { string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); + $""The generated activity helper targets version '{version}' but TaskOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); } return options; } - if (options is ActivityOptions existingActivityOptions) - { - return new ActivityOptions(existingActivityOptions) - { - Version = version, - }; - } - if (options is null) { - return new ActivityOptions + return new TaskOptions { Version = version, }; } - return new ActivityOptions(options) + return new TaskOptions(options) { Version = version, }; @@ -139,10 +130,9 @@ public static Task CallInvoiceActivity_v2Async(this TaskOrchestrationCon static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { - if (options is ActivityOptions activityOptions - && activityOptions.Version is TaskVersion explicitVersion) + if (options?.Version is TaskVersion explicitVersion) { - // Any non-null ActivityOptions.Version is an explicit caller selection — including + // Any non-null TaskOptions.Version is an explicit caller selection — including // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- // baked version is always a contradiction, so we throw rather than silently override. string explicitValue = explicitVersion.Version ?? string.Empty; @@ -150,29 +140,21 @@ public static Task CallInvoiceActivity_v2Async(this TaskOrchestrationCon { string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); + $""The generated activity helper targets version '{version}' but TaskOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); } return options; } - if (options is ActivityOptions existingActivityOptions) - { - return new ActivityOptions(existingActivityOptions) - { - Version = version, - }; - } - if (options is null) { - return new ActivityOptions + return new TaskOptions { Version = version, }; } - return new ActivityOptions(options) + return new TaskOptions(options) { Version = version, }; @@ -227,10 +209,9 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { - if (options is ActivityOptions activityOptions - && activityOptions.Version is TaskVersion explicitVersion) + if (options?.Version is TaskVersion explicitVersion) { - // Any non-null ActivityOptions.Version is an explicit caller selection — including + // Any non-null TaskOptions.Version is an explicit caller selection — including // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- // baked version is always a contradiction, so we throw rather than silently override. string explicitValue = explicitVersion.Version ?? string.Empty; @@ -238,29 +219,21 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex { string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); + $""The generated activity helper targets version '{version}' but TaskOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); } return options; } - if (options is ActivityOptions existingActivityOptions) - { - return new ActivityOptions(existingActivityOptions) - { - Version = version, - }; - } - if (options is null) { - return new ActivityOptions + return new TaskOptions { Version = version, }; } - return new ActivityOptions(options) + return new TaskOptions(options) { Version = version, }; diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs index afebca234..41bda8449 100644 --- a/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxTestOrchestration.cs @@ -47,7 +47,7 @@ public override Task RunAsync(TaskOrchestrationContext context, int inpu => context.CallActivityAsync( "VersionedActivityOverrideActivity", input, - new ActivityOptions + new TaskOptions { Version = "v1", }); diff --git a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs index cb420835a..c49292414 100644 --- a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs +++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs @@ -131,7 +131,7 @@ public void ContinueAsNew_WithPreserveUnprocessedEvents_ForwardsLateArrivingEven } [Fact] - public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrchestrationVersion() + public async Task CallActivityAsync_TaskOptionsVersionOverridesInheritedOrchestrationVersion() { // Arrange TrackingOrchestrationContext innerContext = new("v2"); @@ -142,7 +142,7 @@ public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrch await wrapper.CallActivityAsync( "TestActivity", 123, - new ActivityOptions + new TaskOptions { Version = "v1", }); @@ -154,7 +154,7 @@ await wrapper.CallActivityAsync( } [Fact] - public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrchestrationVersion_WithRetryPolicy() + public async Task CallActivityAsync_TaskOptionsVersionOverridesInheritedOrchestrationVersion_WithRetryPolicy() { // Arrange TrackingOrchestrationContext innerContext = new("v2"); @@ -165,7 +165,7 @@ public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrch await wrapper.CallActivityAsync( "TestActivity", 123, - new ActivityOptions(new RetryPolicy(1, TimeSpan.FromSeconds(1))) + new TaskOptions(new RetryPolicy(1, TimeSpan.FromSeconds(1))) { Version = "v1", }); @@ -177,13 +177,13 @@ await wrapper.CallActivityAsync( } [Fact] - public async Task CallActivityAsync_ActivityOptionsVersionOverridesInheritedOrchestrationVersion_WithRetryHandler() + 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"); - ActivityOptions options = new(TaskOptions.FromRetryHandler(_ => false)) + TaskOptions options = new(TaskOptions.FromRetryHandler(_ => false)) { Version = "v1", }; @@ -256,15 +256,15 @@ public async Task CallActivityAsync_NullOptionsUsesInheritedOrchestrationVersion } [Fact] - public async Task CallActivityAsync_NullActivityOptionsVersion_InheritsOrchestrationVersion() + public async Task CallActivityAsync_NullTaskOptionsVersion_InheritsOrchestrationVersion() { - // Arrange — ActivityOptions present but Version not set => inherit (same as plain TaskOptions). + // 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 ActivityOptions()); + await wrapper.CallActivityAsync("TestActivity", 123, new TaskOptions()); // Assert innerContext.LastScheduledTaskName.Should().Be("TestActivity"); @@ -283,7 +283,7 @@ public async Task CallActivityAsync_ExplicitUnversionedActivityOption_BypassesIn await wrapper.CallActivityAsync( "TestActivity", 123, - new ActivityOptions { Version = TaskVersion.Unversioned }); + new TaskOptions { Version = TaskVersion.Unversioned }); // Assert — empty version is sent (the unversioned activity), instead of inheriting v2. innerContext.LastScheduledTaskName.Should().Be("TestActivity"); From 7f2a30e9d921ccc17660d4a868b1ddbfc57afe02 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 09:31:57 -0700 Subject: [PATCH 46/52] =?UTF-8?q?Phase=206:=20simplify=20generator=20emit?= =?UTF-8?q?=20=E2=80=94=20class-named=20helpers,=20no=20runtime=20conflict?= =?UTF-8?q?=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For multi-class-per-name registrations, generated helper names are derived from the class's simple name rather than from the durable task name plus an encoded version suffix. Single-class registrations keep their existing helper-name shape, so this is non-breaking for the common case. This removes: - The _xHHHH_ method-name encoding scheme (ToVersionSuffix) - The runtime 'options.Version disagrees with helper-baked version' check inside ApplyGeneratedVersion / ApplyGeneratedActivityVersion - The InvalidOperationException-throwing fallback that pushed callers toward the unqualified overloads The simplified ApplyGeneratedVersion now follows the rule: caller- supplied options.Version wins; otherwise stamp the helper's baked version. Discoverability of multi-version helpers is unchanged (IntelliSense shows ScheduleNewOrderWorkflowV1InstanceAsync and ScheduleNewOrderWorkflowV2InstanceAsync directly). Samples are updated to use the new helper names. DURABLE3003 (duplicate name+version) is retained as a compile-time guard against runtime registry-build failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/ActivityVersioningSample/Program.cs | 6 +- samples/ActivityVersioningSample/README.md | 2 +- .../Program.cs | 8 +- .../PerOrchestratorVersioningSample/README.md | 2 +- src/Generators/DurableTaskSourceGenerator.cs | 148 +++------ .../VersionedActivityTests.cs | 79 +---- .../VersionedOrchestratorTests.cs | 296 +++++------------- 7 files changed, 155 insertions(+), 386 deletions(-) diff --git a/samples/ActivityVersioningSample/Program.cs b/samples/ActivityVersioningSample/Program.cs index ea80cd246..995e1ab00 100644 --- a/samples/ActivityVersioningSample/Program.cs +++ b/samples/ActivityVersioningSample/Program.cs @@ -45,13 +45,13 @@ Console.WriteLine(); Console.WriteLine("Scheduling CheckoutWorkflow v1 ..."); -string v1Id = await client.ScheduleNewCheckoutWorkflow_1InstanceAsync(5); +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.ScheduleNewCheckoutWorkflow_2InstanceAsync(5); +string v2Id = await client.ScheduleNewCheckoutWorkflowV2InstanceAsync(5); OrchestrationMetadata v2 = await client.WaitForInstanceCompletionAsync(v2Id, getInputsAndOutputs: true); Console.WriteLine($" Result: {v2.ReadOutputAs()}"); Console.WriteLine(); @@ -113,7 +113,7 @@ public sealed class ExplicitOverrideCheckoutWorkflowV2 : TaskOrchestrator public override async Task RunAsync(TaskOrchestrationContext context, int itemCount) { - string quote = await context.CallShippingQuote_1Async(itemCount); + string quote = await context.CallShippingQuoteV1Async(itemCount); return $"Workflow v2 explicit override -> {quote}"; } } diff --git a/samples/ActivityVersioningSample/README.md b/samples/ActivityVersioningSample/README.md index 13c459da3..f9a628ece 100644 --- a/samples/ActivityVersioningSample/README.md +++ b/samples/ActivityVersioningSample/README.md @@ -7,7 +7,7 @@ This sample demonstrates activity versioning with `[DurableTaskVersion]`, where - Two classes share the same `[DurableTask("ShippingQuote")]` name but have different `[DurableTaskVersion]` values - Two versions of `CheckoutWorkflow` call the same logical activity name in one worker process using the default inherited-routing behavior - The orchestration instance version is still the default for activity scheduling, so `CheckoutWorkflow` v1 routes to `ShippingQuote` v1 and `CheckoutWorkflow` v2 routes to `ShippingQuote` v2 -- Version-qualified activity helpers like `CallShippingQuote_1Async()` and `CallShippingQuote_2Async()` now explicitly select those versions when called from an orchestration +- 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 diff --git a/samples/PerOrchestratorVersioningSample/Program.cs b/samples/PerOrchestratorVersioningSample/Program.cs index 738abd690..f63d473dc 100644 --- a/samples/PerOrchestratorVersioningSample/Program.cs +++ b/samples/PerOrchestratorVersioningSample/Program.cs @@ -43,17 +43,17 @@ Console.WriteLine(); // 1) Schedule an OrderWorkflow version 1 instance. -// The generated helper ScheduleNewOrderWorkflow_1InstanceAsync automatically +// The generated helper ScheduleNewOrderWorkflowV1InstanceAsync automatically // stamps the instance with version "1". Console.WriteLine("Scheduling OrderWorkflow v1 ..."); -string v1Id = await client.ScheduleNewOrderWorkflow_1InstanceAsync(5); +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.ScheduleNewOrderWorkflow_2InstanceAsync(5); +string v2Id = await client.ScheduleNewOrderWorkflowV2InstanceAsync(5); OrchestrationMetadata v2 = await client.WaitForInstanceCompletionAsync(v2Id, getInputsAndOutputs: true); Console.WriteLine($" Result: {v2.ReadOutputAs()}"); Console.WriteLine(); @@ -65,7 +65,7 @@ // to v2 using ContinueAsNewOptions.NewVersion. This is the safest migration point // for eternal orchestrations because the history is fully reset. Console.WriteLine("Scheduling MigratingWorkflow v1 → v2 (ContinueAsNew migration) ..."); -string migrateId = await client.ScheduleNewMigratingWorkflow_1InstanceAsync(new MigrationInput(10)); +string migrateId = await client.ScheduleNewMigratingWorkflowV1InstanceAsync(new MigrationInput(10)); OrchestrationMetadata migrate = await client.WaitForInstanceCompletionAsync(migrateId, getInputsAndOutputs: true); Console.WriteLine($" Result: {migrate.ReadOutputAs()}"); Console.WriteLine(); diff --git a/samples/PerOrchestratorVersioningSample/README.md b/samples/PerOrchestratorVersioningSample/README.md index 680ed7d2a..e0dd33043 100644 --- a/samples/PerOrchestratorVersioningSample/README.md +++ b/samples/PerOrchestratorVersioningSample/README.md @@ -5,7 +5,7 @@ This sample demonstrates per-orchestrator versioning with `[DurableTaskVersion]` ## What it shows - Two classes share the same `[DurableTask("OrderWorkflow")]` name but have different `[DurableTaskVersion]` values -- The source generator produces version-qualified helpers like `ScheduleNewOrderWorkflow_1InstanceAsync()` and `ScheduleNewOrderWorkflow_2InstanceAsync()` +- 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 diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 3e678a24d..79a53a38a 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -677,17 +677,17 @@ public static class GeneratedDurableTaskExtensions AddOrchestratorFunctionDeclaration(sourceBuilder, orchestrator, targetNamespace); } - string helperSuffix = GetStandaloneTaskHelperSuffix(orchestrator, isDurableFunctions, standaloneOrchestratorCountsByTaskName); + string helperRoot = GetStandaloneHelperRoot(orchestrator, isDurableFunctions, standaloneOrchestratorCountsByTaskName); bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(orchestrator.TaskVersion); - AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperSuffix, applyGeneratedVersion); - AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperSuffix, applyGeneratedVersion); + AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperRoot, applyGeneratedVersion); + AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperRoot, applyGeneratedVersion); } foreach (DurableTaskTypeInfo activity in activitiesInNs) { - string helperSuffix = GetStandaloneTaskHelperSuffix(activity, isDurableFunctions, standaloneActivityCountsByTaskName); + string helperRoot = GetStandaloneHelperRoot(activity, isDurableFunctions, standaloneActivityCountsByTaskName); bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(activity.TaskVersion); - AddActivityCallMethod(sourceBuilder, activity, targetNamespace, helperSuffix, applyGeneratedVersion); + AddActivityCallMethod(sourceBuilder, activity, targetNamespace, helperRoot, applyGeneratedVersion); if (isDurableFunctions) { @@ -816,50 +816,35 @@ static string SimplifyTypeName(string fullyQualifiedTypeName, string targetNames return fullyQualifiedTypeName; } - static string GetStandaloneTaskHelperSuffix(DurableTaskTypeInfo task, bool isDurableFunctions, Dictionary standaloneTaskCountsByTaskName) + 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 string.Empty; + return task.TaskName; } - return ToVersionSuffix(task.TaskVersion); + return GetSimpleClassName(task.TypeName); } - static string GetStandaloneTaskRegistrationKey(string taskName, string taskVersion) + static string GetSimpleClassName(string fullyQualifiedTypeName) { - return string.Concat(taskName, "\0", taskVersion); + // Strip namespaces and any outer-class prefixes. For "MyApp.Workflows.OrderWorkflowV2" returns + // "OrderWorkflowV2". For nested "Outer.Inner" returns "Inner". + int lastDot = fullyQualifiedTypeName.LastIndexOf('.'); + return lastDot < 0 ? fullyQualifiedTypeName : fullyQualifiedTypeName.Substring(lastDot + 1); } - static string ToVersionSuffix(string version) + static string GetStandaloneTaskRegistrationKey(string taskName, string taskVersion) { - if (string.IsNullOrEmpty(version)) - { - return string.Empty; - } - - // Encode the version as a method-name suffix that is collision-free with respect to any other input. - // Letters and digits pass through; '_' and every other character are encoded as "_xHHHH_". Escaping - // '_' itself is what prevents collisions like "1.0" (-> "_1_x002E_0") vs "1_x002E_0" which would - // otherwise both produce "_1_x002E_0". - StringBuilder suffixBuilder = new(version.Length + 1); - suffixBuilder.Append('_'); - foreach (char c in version) - { - if (char.IsLetterOrDigit(c)) - { - suffixBuilder.Append(c); - } - else - { - suffixBuilder.Append("_x").Append(((int)c).ToString("X4", CultureInfo.InvariantCulture)).Append('_'); - } - } - - return suffixBuilder.ToString(); + return string.Concat(taskName, "\0", taskVersion); } static string ToCSharpStringLiteral(string value) => SymbolDisplay.FormatLiteral(value, quote: true); @@ -878,7 +863,7 @@ static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, Dura }}"); } - static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace, string helperSuffix, bool applyGeneratedVersion) + static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace, string helperRoot, bool applyGeneratedVersion) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); string inputParameter = inputType + " input"; @@ -897,14 +882,14 @@ static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTy /// Schedules a new instance of the orchestrator. /// /// - public static Task ScheduleNew{orchestrator.TaskName}{helperSuffix}InstanceAsync( + public static Task ScheduleNew{helperRoot}InstanceAsync( this IOrchestrationSubmitter client, {inputParameter}, StartOrchestrationOptions? options = null) {{ return client.ScheduleNewOrchestrationInstanceAsync(""{orchestrator.TaskName}"", input, {optionsExpression}); }}"); } - static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace, string helperSuffix, bool applyGeneratedVersion) + static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace, string helperRoot, bool applyGeneratedVersion) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); string outputType = orchestrator.GetOutputTypeForNamespace(targetNamespace); @@ -924,7 +909,7 @@ static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTas /// Calls the sub-orchestrator. /// /// - public static Task<{outputType}> Call{orchestrator.TaskName}{helperSuffix}Async( + public static Task<{outputType}> Call{helperRoot}Async( this TaskOrchestrationContext context, {inputParameter}, TaskOptions? options = null) {{ return context.CallSubOrchestratorAsync<{outputType}>(""{orchestrator.TaskName}"", input, {optionsExpression}); @@ -941,23 +926,8 @@ static void AddStandaloneGeneratedVersionHelperMethods( sourceBuilder.AppendLine(@" static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is TaskVersion existingStart) - { - // Any non-null Version on the options is an explicit caller selection — including - // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a - // specific version into its method name, so a disagreement is always a contradiction; - // the silently-overridden case is precisely what we are trying to prevent. - string existingValue = existingStart.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); - } - - return options; - } - + // 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 @@ -966,33 +936,23 @@ static void AddStandaloneGeneratedVersionHelperMethods( }; } - return new StartOrchestrationOptions(options) + if (options.Version is not null) { - Version = version, - }; + return options; + } + + return options with { Version = new TaskVersion(version) }; } static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) - { - string existingValue = existingSub.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); - } - - return options; - } - + // 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 new SubOrchestrationOptions(subOrchestrationOptions) - { - Version = version, - }; + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; } if (options is null) @@ -1003,10 +963,9 @@ static void AddStandaloneGeneratedVersionHelperMethods( }; } - return new SubOrchestrationOptions(options) - { - Version = version, - }; + return options.Version is not null + ? options + : new SubOrchestrationOptions(options) { Version = version }; }"); } @@ -1015,22 +974,8 @@ static void AddStandaloneGeneratedVersionHelperMethods( sourceBuilder.AppendLine(@" static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { - if (options?.Version is TaskVersion explicitVersion) - { - // Any non-null TaskOptions.Version is an explicit caller selection — including - // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- - // baked version is always a contradiction, so we throw rather than silently override. - string explicitValue = explicitVersion.Version ?? string.Empty; - if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; - throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but TaskOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); - } - - return options; - } - + // 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 @@ -1039,15 +984,14 @@ static void AddStandaloneGeneratedVersionHelperMethods( }; } - return new TaskOptions(options) - { - Version = version, - }; + return options.Version is not null + ? options + : new TaskOptions(options) { Version = version }; }"); } } - static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace, string helperSuffix, bool applyGeneratedVersion) + static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace, string helperRoot, bool applyGeneratedVersion) { string inputType = activity.GetInputTypeForNamespace(targetNamespace); string outputType = activity.GetOutputTypeForNamespace(targetNamespace); @@ -1067,7 +1011,7 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeIn /// Calls the activity. /// /// - public static Task<{outputType}> Call{activity.TaskName}{helperSuffix}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, {optionsExpression}); }}"); diff --git a/test/Generators.Tests/VersionedActivityTests.cs b/test/Generators.Tests/VersionedActivityTests.cs index fafb15c9d..d4c949c2f 100644 --- a/test/Generators.Tests/VersionedActivityTests.cs +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -43,22 +43,8 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { - if (options?.Version is TaskVersion explicitVersion) - { - // Any non-null TaskOptions.Version is an explicit caller selection — including - // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- - // baked version is always a contradiction, so we throw rather than silently override. - string explicitValue = explicitVersion.Version ?? string.Empty; - if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; - throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but TaskOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); - } - - return options; - } - + // 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 @@ -67,10 +53,9 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex }; } - return new TaskOptions(options) - { - Version = version, - }; + return options.Version is not null + ? options + : new TaskOptions(options) { Version = version }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) @@ -114,7 +99,7 @@ class InvoiceActivityV2 : TaskActivity /// Calls the activity. /// /// -public static Task CallInvoiceActivity_v1Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +public static Task CallInvoiceActivityV1Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) { return ctx.CallActivityAsync(""InvoiceActivity"", input, ApplyGeneratedActivityVersion(options, ""v1"")); } @@ -123,29 +108,15 @@ public static Task CallInvoiceActivity_v1Async(this TaskOrchestrationCon /// Calls the activity. /// /// -public static Task CallInvoiceActivity_v2Async(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) +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) { - if (options?.Version is TaskVersion explicitVersion) - { - // Any non-null TaskOptions.Version is an explicit caller selection — including - // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- - // baked version is always a contradiction, so we throw rather than silently override. - string explicitValue = explicitVersion.Version ?? string.Empty; - if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; - throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but TaskOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); - } - - return options; - } - + // 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 @@ -154,10 +125,9 @@ public static Task CallInvoiceActivity_v2Async(this TaskOrchestrationCon }; } - return new TaskOptions(options) - { - Version = version, - }; + return options.Version is not null + ? options + : new TaskOptions(options) { Version = version }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) @@ -209,22 +179,8 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) { - if (options?.Version is TaskVersion explicitVersion) - { - // Any non-null TaskOptions.Version is an explicit caller selection — including - // TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper- - // baked version is always a contradiction, so we throw rather than silently override. - string explicitValue = explicitVersion.Version ?? string.Empty; - if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(explicitValue) ? """" : ""'"" + explicitValue + ""'""; - throw new System.InvalidOperationException( - $""The generated activity helper targets version '{version}' but TaskOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version.""); - } - - return options; - } - + // 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 @@ -233,10 +189,9 @@ public static Task CallInvoiceActivityAsync(this TaskOrchestrationContex }; } - return new TaskOptions(options) - { - Version = version, - }; + return options.Version is not null + ? options + : new TaskOptions(options) { Version = version }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) diff --git a/test/Generators.Tests/VersionedOrchestratorTests.cs b/test/Generators.Tests/VersionedOrchestratorTests.cs index f81d23042..29e7e154d 100644 --- a/test/Generators.Tests/VersionedOrchestratorTests.cs +++ b/test/Generators.Tests/VersionedOrchestratorTests.cs @@ -52,23 +52,8 @@ public static Task CallInvoiceWorkflowAsync( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is TaskVersion existingStart) - { - // Any non-null Version on the options is an explicit caller selection — including - // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a - // specific version into its method name, so a disagreement is always a contradiction; - // the silently-overridden case is precisely what we are trying to prevent. - string existingValue = existingStart.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); - } - - return options; - } - + // 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 @@ -77,33 +62,23 @@ public static Task CallInvoiceWorkflowAsync( }; } - return new StartOrchestrationOptions(options) + if (options.Version is not null) { - Version = version, - }; + return options; + } + + return options with { Version = new TaskVersion(version) }; } static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) - { - string existingValue = existingSub.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); - } - - return options; - } - + // 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 new SubOrchestrationOptions(subOrchestrationOptions) - { - Version = version, - }; + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; } if (options is null) @@ -114,10 +89,9 @@ public static Task CallInvoiceWorkflowAsync( }; } - return new SubOrchestrationOptions(options) - { - Version = version, - }; + return options.Version is not null + ? options + : new SubOrchestrationOptions(options) { Version = version }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) @@ -161,7 +135,7 @@ class InvoiceWorkflowV2 : TaskOrchestrator /// Schedules a new instance of the orchestrator. /// /// -public static Task ScheduleNewInvoiceWorkflow_v1InstanceAsync( +public static Task ScheduleNewInvoiceWorkflowV1InstanceAsync( this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) { return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); @@ -171,7 +145,7 @@ public static Task ScheduleNewInvoiceWorkflow_v1InstanceAsync( /// Calls the sub-orchestrator. /// /// -public static Task CallInvoiceWorkflow_v1Async( +public static Task CallInvoiceWorkflowV1Async( this TaskOrchestrationContext context, int input, TaskOptions? options = null) { return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); @@ -181,7 +155,7 @@ public static Task CallInvoiceWorkflow_v1Async( /// Schedules a new instance of the orchestrator. /// /// -public static Task ScheduleNewInvoiceWorkflow_v2InstanceAsync( +public static Task ScheduleNewInvoiceWorkflowV2InstanceAsync( this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) { return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v2"")); @@ -191,7 +165,7 @@ public static Task ScheduleNewInvoiceWorkflow_v2InstanceAsync( /// Calls the sub-orchestrator. /// /// -public static Task CallInvoiceWorkflow_v2Async( +public static Task CallInvoiceWorkflowV2Async( this TaskOrchestrationContext context, int input, TaskOptions? options = null) { return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v2"")); @@ -199,23 +173,8 @@ public static Task CallInvoiceWorkflow_v2Async( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is TaskVersion existingStart) - { - // Any non-null Version on the options is an explicit caller selection — including - // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a - // specific version into its method name, so a disagreement is always a contradiction; - // the silently-overridden case is precisely what we are trying to prevent. - string existingValue = existingStart.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); - } - - return options; - } - + // 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 @@ -224,33 +183,23 @@ public static Task CallInvoiceWorkflow_v2Async( }; } - return new StartOrchestrationOptions(options) + if (options.Version is not null) { - Version = version, - }; + return options; + } + + return options with { Version = new TaskVersion(version) }; } static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) - { - string existingValue = existingSub.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); - } - - return options; - } - + // 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 new SubOrchestrationOptions(subOrchestrationOptions) - { - Version = version, - }; + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; } if (options is null) @@ -261,10 +210,9 @@ public static Task CallInvoiceWorkflow_v2Async( }; } - return new SubOrchestrationOptions(options) - { - Version = version, - }; + return options.Version is not null + ? options + : new SubOrchestrationOptions(options) { Version = version }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) @@ -309,7 +257,7 @@ class InvoiceWorkflowV2 : TaskOrchestrator /// Schedules a new instance of the orchestrator. /// /// -public static Task ScheduleNewInvoiceWorkflow_v1InstanceAsync( +public static Task ScheduleNewInvoiceWorkflowV1InstanceAsync( this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) { return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); @@ -319,7 +267,7 @@ public static Task ScheduleNewInvoiceWorkflow_v1InstanceAsync( /// Calls the sub-orchestrator. /// /// -public static Task CallInvoiceWorkflow_v1Async( +public static Task CallInvoiceWorkflowV1Async( this TaskOrchestrationContext context, int input, TaskOptions? options = null) { return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, ""v1"")); @@ -329,7 +277,7 @@ public static Task CallInvoiceWorkflow_v1Async( /// Schedules a new instance of the orchestrator. /// /// -public static Task ScheduleNewinvoiceworkflow_v2InstanceAsync( +public static Task ScheduleNewInvoiceWorkflowV2InstanceAsync( this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) { return client.ScheduleNewOrchestrationInstanceAsync(""invoiceworkflow"", input, ApplyGeneratedVersion(options, ""v2"")); @@ -339,7 +287,7 @@ public static Task ScheduleNewinvoiceworkflow_v2InstanceAsync( /// Calls the sub-orchestrator. /// /// -public static Task Callinvoiceworkflow_v2Async( +public static Task CallInvoiceWorkflowV2Async( this TaskOrchestrationContext context, int input, TaskOptions? options = null) { return context.CallSubOrchestratorAsync(""invoiceworkflow"", input, ApplyGeneratedVersion(options, ""v2"")); @@ -347,23 +295,8 @@ public static Task Callinvoiceworkflow_v2Async( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is TaskVersion existingStart) - { - // Any non-null Version on the options is an explicit caller selection — including - // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a - // specific version into its method name, so a disagreement is always a contradiction; - // the silently-overridden case is precisely what we are trying to prevent. - string existingValue = existingStart.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); - } - - return options; - } - + // 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 @@ -372,33 +305,23 @@ public static Task Callinvoiceworkflow_v2Async( }; } - return new StartOrchestrationOptions(options) + if (options.Version is not null) { - Version = version, - }; + return options; + } + + return options with { Version = new TaskVersion(version) }; } static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) - { - string existingValue = existingSub.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); - } - - return options; - } - + // 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 new SubOrchestrationOptions(subOrchestrationOptions) - { - Version = version, - }; + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; } if (options is null) @@ -409,10 +332,9 @@ public static Task Callinvoiceworkflow_v2Async( }; } - return new SubOrchestrationOptions(options) - { - Version = version, - }; + return options.Version is not null + ? options + : new SubOrchestrationOptions(options) { Version = version }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) @@ -475,23 +397,8 @@ public static Task CallInvoiceWorkflowAsync( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is TaskVersion existingStart) - { - // Any non-null Version on the options is an explicit caller selection — including - // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a - // specific version into its method name, so a disagreement is always a contradiction; - // the silently-overridden case is precisely what we are trying to prevent. - string existingValue = existingStart.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); - } - - return options; - } - + // 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 @@ -500,33 +407,23 @@ public static Task CallInvoiceWorkflowAsync( }; } - return new StartOrchestrationOptions(options) + if (options.Version is not null) { - Version = version, - }; + return options; + } + + return options with { Version = new TaskVersion(version) }; } static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) - { - string existingValue = existingSub.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); - } - - return options; - } - + // 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 new SubOrchestrationOptions(subOrchestrationOptions) - { - Version = version, - }; + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; } if (options is null) @@ -537,10 +434,9 @@ public static Task CallInvoiceWorkflowAsync( }; } - return new SubOrchestrationOptions(options) - { - Version = version, - }; + return options.Version is not null + ? options + : new SubOrchestrationOptions(options) { Version = version }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) @@ -619,23 +515,8 @@ public static Task CallInvoiceWorkflowAsync( static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) { - if (options?.Version is TaskVersion existingStart) - { - // Any non-null Version on the options is an explicit caller selection — including - // TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a - // specific version into its method name, so a disagreement is always a contradiction; - // the silently-overridden case is precisely what we are trying to prevent. - string existingValue = existingStart.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version.""); - } - - return options; - } - + // 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 @@ -644,33 +525,23 @@ public static Task CallInvoiceWorkflowAsync( }; } - return new StartOrchestrationOptions(options) + if (options.Version is not null) { - Version = version, - }; + return options; + } + + return options with { Version = new TaskVersion(version) }; } static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) { - if (options is SubOrchestrationOptions { Version: TaskVersion existingSub }) - { - string existingValue = existingSub.Version ?? string.Empty; - if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase)) - { - string requested = string.IsNullOrEmpty(existingValue) ? """" : ""'"" + existingValue + ""'""; - throw new System.InvalidOperationException( - $""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version.""); - } - - return options; - } - + // 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 new SubOrchestrationOptions(subOrchestrationOptions) - { - Version = version, - }; + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; } if (options is null) @@ -681,10 +552,9 @@ public static Task CallInvoiceWorkflowAsync( }; } - return new SubOrchestrationOptions(options) - { - Version = version, - }; + return options.Version is not null + ? options + : new SubOrchestrationOptions(options) { Version = version }; } internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) From ce225256def33b0b27cde2da08e29a2276df7ede Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 09:55:59 -0700 Subject: [PATCH 47/52] Address code review feedback on the simplification pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Important fixes: - **Composition smoke test for UseVersioning + multi-version registry.** Adds UseVersioning_CurrentOrOlder_WithMultiVersionRegistry_RoutesEachVersionToItsImplementation which configures the worker with UseVersioning(CurrentOrOlder, Version=v2), registers V1 + V2, and verifies that v1 and v2 instances each route to their matching implementation. This is the central composition property of the simplification — previously only proven by a does-not-throw construction test. - **Nested-class helper-name uniqueness.** GetSimpleClassName was renamed to GetClassRelativeName and now strips the containing namespace prefix and joins any outer-class chain with '_' (e.g., 'Outer.Inner' becomes 'Outer_Inner'). Without this, two [DurableTask('Foo')] classes named Inner nested under different outer types would produce colliding extension-method names and a downstream CS0111 instead of a clean diagnostic. - **Remove the dead registryMonitor constructor parameter** on GrpcDurableTaskWorker. The parameter was retained for backward compatibility after Phase 4 dropped WorkerVersioningPolicy.EnsureNotCombined, but the PR is pre-release so the signature stability buys nothing and a documented-but-dead parameter is worse than a removal. Test call site updated. Minor fixes: - Drop the dead 'this.worker.Factory is not IVersionedTaskFactory' guard from the activity not-found error message — DurableTaskFactory always implements IVersionedTaskFactory, so the clause never fires and was asymmetric with the orchestrator path. - Add to generated helper XML docs explaining that a non-null options.Version overrides the helper's baked version. Surfaces the Phase 6 silent-override behavior in IntelliSense without re-introducing the runtime conflict check. - Rename Constructor_PerTaskVersioning_* tests to Constructor_MultiVersionRegistry_* to match the new vocabulary (per-task versioning is no longer a distinct concept; it is an extension of UseVersioning). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Generators/DurableTaskSourceGenerator.cs | 35 +++++++++--- .../Grpc/GrpcDurableTaskWorker.Processor.cs | 2 +- src/Worker/Grpc/GrpcDurableTaskWorker.cs | 4 +- .../VersionedActivityTests.cs | 4 ++ .../VersionedOrchestratorTests.cs | 14 +++++ .../VersionedClassSyntaxIntegrationTests.cs | 56 +++++++++++++++++++ .../Grpc.Tests/GrpcDurableTaskWorkerTests.cs | 14 +++-- 7 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 79a53a38a..80812b85a 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -831,15 +831,20 @@ static string GetStandaloneHelperRoot(DurableTaskTypeInfo task, bool isDurableFu return task.TaskName; } - return GetSimpleClassName(task.TypeName); + return GetClassRelativeName(task.TypeName, task.Namespace); } - static string GetSimpleClassName(string fullyQualifiedTypeName) + static string GetClassRelativeName(string fullyQualifiedTypeName, string classNamespace) { - // Strip namespaces and any outer-class prefixes. For "MyApp.Workflows.OrderWorkflowV2" returns - // "OrderWorkflowV2". For nested "Outer.Inner" returns "Inner". - int lastDot = fullyQualifiedTypeName.LastIndexOf('.'); - return lastDot < 0 ? fullyQualifiedTypeName : fullyQualifiedTypeName.Substring(lastDot + 1); + // 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) @@ -876,11 +881,15 @@ static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTy 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{helperRoot}InstanceAsync( this IOrchestrationSubmitter client, {inputParameter}, StartOrchestrationOptions? options = null) @@ -903,11 +912,15 @@ static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTas 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{helperRoot}Async( this TaskOrchestrationContext context, {inputParameter}, TaskOptions? options = null) @@ -1005,11 +1018,15 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeIn 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{helperRoot}Async(this TaskOrchestrationContext ctx, {inputParameter}, TaskOptions? options = null) {{ diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index 75085e578..e92494c37 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -927,7 +927,7 @@ async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, failureDetails = new P.TaskFailureDetails { ErrorType = "ActivityTaskNotFound", - ErrorMessage = string.IsNullOrEmpty(versionText) || this.worker.Factory is not IVersionedTaskFactory + 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/src/Worker/Grpc/GrpcDurableTaskWorker.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.cs index a9cc392d5..a200cedbf 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.cs @@ -35,7 +35,6 @@ sealed partial class GrpcDurableTaskWorker : DurableTaskWorker /// The optional used to filter orchestration execution. /// The custom exception properties provider that help build failure details. /// The optional used to filter work items in the backend. - /// The optional used to detect per-task versioning configuration. public GrpcDurableTaskWorker( string name, IDurableTaskFactory factory, @@ -45,8 +44,7 @@ public GrpcDurableTaskWorker( ILoggerFactory loggerFactory, IOrchestrationFilter? orchestrationFilter = null, IExceptionPropertiesProvider? exceptionPropertiesProvider = null, - IOptionsMonitor? workItemFiltersMonitor = null, - IOptionsMonitor? registryMonitor = null) + IOptionsMonitor? workItemFiltersMonitor = null) : base(name, factory) { this.grpcOptions = Check.NotNull(grpcOptions).Get(name); diff --git a/test/Generators.Tests/VersionedActivityTests.cs b/test/Generators.Tests/VersionedActivityTests.cs index d4c949c2f..9edabc928 100644 --- a/test/Generators.Tests/VersionedActivityTests.cs +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -35,6 +35,7 @@ class InvoiceActivity : TaskActivity /// /// 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) { @@ -98,6 +99,7 @@ class InvoiceActivityV2 : TaskActivity /// /// 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) { @@ -107,6 +109,7 @@ public static Task CallInvoiceActivityV1Async(this TaskOrchestrationCont /// /// 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) { @@ -171,6 +174,7 @@ class InvoiceActivityV1Duplicate : TaskActivity /// /// 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) { diff --git a/test/Generators.Tests/VersionedOrchestratorTests.cs b/test/Generators.Tests/VersionedOrchestratorTests.cs index 29e7e154d..c19d5e86c 100644 --- a/test/Generators.Tests/VersionedOrchestratorTests.cs +++ b/test/Generators.Tests/VersionedOrchestratorTests.cs @@ -33,6 +33,7 @@ class InvoiceWorkflow : TaskOrchestrator /// /// 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) @@ -43,6 +44,7 @@ public static Task ScheduleNewInvoiceWorkflowInstanceAsync( /// /// 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) @@ -134,6 +136,7 @@ class InvoiceWorkflowV2 : TaskOrchestrator /// /// 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) @@ -144,6 +147,7 @@ public static Task ScheduleNewInvoiceWorkflowV1InstanceAsync( /// /// 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) @@ -154,6 +158,7 @@ public static Task CallInvoiceWorkflowV1Async( /// /// 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) @@ -164,6 +169,7 @@ public static Task ScheduleNewInvoiceWorkflowV2InstanceAsync( /// /// 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) @@ -256,6 +262,7 @@ class InvoiceWorkflowV2 : TaskOrchestrator /// /// 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) @@ -266,6 +273,7 @@ public static Task ScheduleNewInvoiceWorkflowV1InstanceAsync( /// /// 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) @@ -276,6 +284,7 @@ public static Task CallInvoiceWorkflowV1Async( /// /// 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) @@ -286,6 +295,7 @@ public static Task ScheduleNewInvoiceWorkflowV2InstanceAsync( /// /// 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) @@ -378,6 +388,7 @@ class InvoiceWorkflowV1Duplicate : TaskOrchestrator /// /// 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) @@ -388,6 +399,7 @@ public static Task ScheduleNewInvoiceWorkflowInstanceAsync( /// /// 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) @@ -496,6 +508,7 @@ class InvoiceWorkflowV1Duplicate : TaskOrchestrator /// /// 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) @@ -506,6 +519,7 @@ public static Task ScheduleNewInvoiceWorkflowInstanceAsync( /// /// 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) diff --git a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs index a960742b1..01b97aa54 100644 --- a/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs +++ b/test/Grpc.IntegrationTests/VersionedClassSyntaxIntegrationTests.cs @@ -172,4 +172,60 @@ public async Task ClassBasedVersionedOrchestrator_ContinueAsNewNewVersionRoutesT 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/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index 0dfb19d82..ca20bfbfe 100644 --- a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs +++ b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs @@ -490,7 +490,7 @@ public async Task ConnectAsync_VeryLargeHelloDeadline_UsesUtcMaxValueDeadline() } [Fact] - public void Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_DoesNotThrow() + 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 @@ -517,7 +517,7 @@ public void Constructor_PerTaskVersioningCombinedWithStrictWorkerVersioning_Does } [Fact] - public void Constructor_PerTaskVersioningWithoutWorkerVersioning_DoesNotThrow() + public void Constructor_MultiVersionRegistryWithoutWorkerVersioning_DoesNotThrow() { // Arrange DurableTaskRegistry registry = new(); @@ -535,7 +535,7 @@ public void Constructor_PerTaskVersioningWithoutWorkerVersioning_DoesNotThrow() } [Fact] - public void Constructor_WorkerVersioningWithoutPerTaskVersions_DoesNotThrow() + public void Constructor_WorkerVersioningWithoutMultiVersionRegistry_DoesNotThrow() { // Arrange DurableTaskRegistry registry = new(); @@ -588,6 +588,11 @@ static GrpcDurableTaskWorker CreateWorker( IDurableTaskFactory factory, DurableTaskRegistry? registry) { + // The registry parameter is preserved on the helper signature for callers that build a factory + // from a registry below; the worker itself no longer reads registry contents at construction time + // since multi-version registration is just (name, version) keys in the registry it already + // consumes via factory. + _ = registry; return new GrpcDurableTaskWorker( name: "Test", factory: factory, @@ -597,8 +602,7 @@ static GrpcDurableTaskWorker CreateWorker( loggerFactory: loggerFactory, orchestrationFilter: null, exceptionPropertiesProvider: null, - workItemFiltersMonitor: null, - registryMonitor: registry is null ? null : new OptionsMonitorStub(registry)); + workItemFiltersMonitor: null); } static Task InvokeExecuteAsync(GrpcDurableTaskWorker worker, CancellationToken cancellationToken) From 01e17ef30f74926142bce82b18fe78dc0cd41adc Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 10:06:45 -0700 Subject: [PATCH 48/52] Update sample and root README guidance to reflect feature composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 4 simplification removed the WorkerVersioningPolicy.EnsureNotCombined guard, so worker-level UseVersioning() and per-class [DurableTaskVersion] registration now compose. Two README notes still said 'do not combine' the two features: - README.md:187 (repo root, Per-Orchestrator Versioning section) - samples/PerOrchestratorVersioningSample/README.md:67 Both rewritten to explain that the two features compose: UseVersioning() filters which instance versions the worker accepts via MatchStrategy, and the per-class registry dispatches accepted work items by exact (name, version) match. Also extends the PerOrchestratorVersioningSample expected-output block to include the MigratingWorkflow ContinueAsNew migration scenario that Program.cs actually executes. Verified by running all three samples end-to-end against a local mcr.microsoft.com/dts/dts-emulator instance: - PerOrchestratorVersioningSample: v1 + v2 + MigratingWorkflow ContinueAsNew → matches expected output - ActivityVersioningSample: CheckoutWorkflow v1/v2 + explicit ShippingQuoteV1 override → matches expected output - WorkerVersioningSample (1.0 + 2.0): stamps the configured worker version onto the instance → matches expected output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- samples/PerOrchestratorVersioningSample/README.md | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a63b3dfa..edfa34808 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( Use `ContinueAsNewOptions.NewVersion` to migrate long-running orchestrations at a replay-safe boundary. -> Do not combine per-orchestrator `[DurableTaskVersion]` routing with `DurableTaskWorkerOptions.Versioning` (or `UseVersioning(...)`). Both features use the orchestration instance version field, so worker-level version checks can reject per-orchestrator versions before class-based routing occurs. +> 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. diff --git a/samples/PerOrchestratorVersioningSample/README.md b/samples/PerOrchestratorVersioningSample/README.md index e0dd33043..81dddeda9 100644 --- a/samples/PerOrchestratorVersioningSample/README.md +++ b/samples/PerOrchestratorVersioningSample/README.md @@ -46,6 +46,11 @@ 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 @@ -64,4 +69,4 @@ Per-orchestrator versioning is useful when: For simpler deployment-based versioning, see the [WorkerVersioningSample](../WorkerVersioningSample/README.md). -> **Note:** Do not combine `[DurableTaskVersion]` routing with worker-level `UseVersioning()` in the same worker. Both features use the orchestration instance version field. +> **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. From 85ce3f19cd3d6404bc6b8f0a9ca1b90cc9d7fa8d Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 10:46:57 -0700 Subject: [PATCH 49/52] Address unresolved PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substantive fixes: 1. TaskVersion.Version is now truly never-null. Switched the auto-property to a computed getter backed by an explicit private field that coalesces to string.Empty. Previously default(TaskVersion) returned null from the Version getter — the XML doc promise was a lie for zero-initialized struct instances. The sub-orchestration version selection in TaskOrchestrationContextWrapper (line 224, reads v.Version directly when options.Version is TaskVersion.Unversioned) now cannot leak a null string into DurableTask.Core. Equality/hash code still defend with ?? string.Empty, now redundant but harmless. 2. Remove the unused DurableTaskRegistry parameter from the Grpc.Tests CreateWorker helper. After Phase 4 deleted EnsureNotCombined, the registry was no longer inspected at construction; the helper had been left with '_ = registry;' to keep the call sites stable, but the three combined- options tests never relied on registry contents. Helper signature trimmed and the test bodies no longer construct a registry that goes unused. The runtime composition behavior is covered end-to-end by UseVersioning_CurrentOrOlder_WithMultiVersionRegistry_RoutesEachVersionToItsImplementation in the integration tests. 3. WorkerVersioningSample now actually demonstrates worker-level versioning. Added UseVersioning(MatchStrategy = Strict, FailureStrategy = Reject) to the worker config so the sample's title matches its behavior. Client-side UseDefaultVersion stamps the same WORKER_VERSION value, and the worker only accepts instances matching that version. README updated. Verified end-to-end against the DTS emulator with WORKER_VERSION=1.0 and 2.0. Style nit fix: 4. Collapse the activity-dispatch if/else into a ternary in GrpcDurableTaskWorker.Processor.cs, matching the orchestrator-dispatch pattern at line 724. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/WorkerVersioningSample/Program.cs | 24 +++++++++---- samples/WorkerVersioningSample/README.md | 7 ++-- src/Abstractions/TaskVersion.cs | 18 +++++----- .../Grpc/GrpcDurableTaskWorker.Processor.cs | 15 +++----- .../Grpc.Tests/GrpcDurableTaskWorkerTests.cs | 36 ++++--------------- 5 files changed, 42 insertions(+), 58 deletions(-) diff --git a/samples/WorkerVersioningSample/Program.cs b/samples/WorkerVersioningSample/Program.cs index 9e4d0eace..c8b82649c 100644 --- a/samples/WorkerVersioningSample/Program.cs +++ b/samples/WorkerVersioningSample/Program.cs @@ -2,12 +2,14 @@ // Licensed under the MIT License. // This sample demonstrates worker-level versioning. Each worker deployment is pinned -// to a single version string via UseDefaultVersion(). The client stamps new orchestration -// instances with that version. To upgrade, you deploy a new worker binary with the -// updated implementation. +// 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 -// the version is associated with the orchestration instance. +// 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; @@ -30,7 +32,8 @@ // when deploying a new version of your worker with updated orchestration logic. string workerVersion = builder.Configuration.GetValue("WORKER_VERSION") ?? "1.0"; -// Configure the worker with an orchestration. +// 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 => @@ -40,6 +43,14 @@ (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); }); @@ -59,7 +70,8 @@ Console.WriteLine($"=== Worker-level versioning (version: {workerVersion}) ==="); Console.WriteLine(); -// Schedule a greeting orchestration. The version is automatically stamped by the client. +// 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}"); diff --git a/samples/WorkerVersioningSample/README.md b/samples/WorkerVersioningSample/README.md index 506575c53..d85e66b96 100644 --- a/samples/WorkerVersioningSample/README.md +++ b/samples/WorkerVersioningSample/README.md @@ -4,9 +4,10 @@ This sample demonstrates worker-level versioning, where each worker deployment i ## What it shows -- The client uses `UseDefaultVersion()` to stamp every new orchestration instance with a version -- The orchestration reads `context.Version` to see what version it was scheduled with -- To "upgrade," you redeploy the worker with a new implementation and change the version string +- 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 diff --git a/src/Abstractions/TaskVersion.cs b/src/Abstractions/TaskVersion.cs index bcd8beabf..fbdb13bc2 100644 --- a/src/Abstractions/TaskVersion.cs +++ b/src/Abstractions/TaskVersion.cs @@ -19,6 +19,8 @@ namespace Microsoft.DurableTask; /// public static readonly TaskVersion Unversioned = default; + readonly string? versionValue; + /// /// Initializes a new instance of the struct. /// @@ -31,13 +33,11 @@ namespace Microsoft.DurableTask; public TaskVersion(string version) { // Normalize null/empty to string.Empty so default(TaskVersion), TaskVersion.Unversioned, and - // new TaskVersion("") all compare and hash identically. Without this normalization the struct's - // Version field can be null, which makes Equals(null, "") return false and causes - // StringComparer.OrdinalIgnoreCase.GetHashCode to throw at runtime when the struct is used as a - // dictionary key. + // 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 = string.Empty; + this.versionValue = string.Empty; return; } @@ -48,14 +48,14 @@ public TaskVersion(string version) nameof(version)); } - this.Version = version; + this.versionValue = version; } /// - /// Gets the version of a task. Returns for an unversioned task; never - /// returns null. + /// 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. diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index e92494c37..8f7458b2b 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -899,20 +899,13 @@ async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, TaskVersion requestedVersion = string.IsNullOrWhiteSpace(request.Version) ? default : new TaskVersion(request.Version); - ITaskActivity? activity; - bool found; - if (this.worker.Factory is IVersionedTaskFactory versionedFactory) - { - found = versionedFactory.TryCreateActivity( + bool found = this.worker.Factory is IVersionedTaskFactory versionedFactory + ? versionedFactory.TryCreateActivity( name, requestedVersion, scope.ServiceProvider, - out activity); - } - else - { - found = this.worker.Factory.TryCreateActivity(name, scope.ServiceProvider, out activity); - } + out ITaskActivity? activity) + : this.worker.Factory.TryCreateActivity(name, scope.ServiceProvider, out activity); if (found) { diff --git a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index ca20bfbfe..fde8e49b6 100644 --- a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs +++ b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs @@ -495,10 +495,8 @@ public void Constructor_MultiVersionRegistryWithStrictWorkerVersioning_DoesNotTh // 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. - DurableTaskRegistry registry = new(); - registry.AddOrchestrator(new TaskName("MyOrch"), new TaskVersion("1"), () => Mock.Of()); - registry.AddOrchestrator(new TaskName("MyOrch"), new TaskVersion("2"), () => Mock.Of()); + // 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 @@ -510,7 +508,7 @@ public void Constructor_MultiVersionRegistryWithStrictWorkerVersioning_DoesNotTh }; // Act - Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance, registry); + Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance); // Assert act.Should().NotThrow(); @@ -520,15 +518,13 @@ public void Constructor_MultiVersionRegistryWithStrictWorkerVersioning_DoesNotTh public void Constructor_MultiVersionRegistryWithoutWorkerVersioning_DoesNotThrow() { // Arrange - DurableTaskRegistry registry = new(); - registry.AddOrchestrator(new TaskName("MyOrch"), new TaskVersion("1"), () => Mock.Of()); DurableTaskWorkerOptions workerOptions = new() { Logging = { UseLegacyCategories = false }, }; // Act - Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance, registry); + Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance); // Assert act.Should().NotThrow(); @@ -538,8 +534,6 @@ public void Constructor_MultiVersionRegistryWithoutWorkerVersioning_DoesNotThrow public void Constructor_WorkerVersioningWithoutMultiVersionRegistry_DoesNotThrow() { // Arrange - DurableTaskRegistry registry = new(); - registry.AddOrchestrator(new TaskName("MyOrch"), () => Mock.Of()); DurableTaskWorkerOptions workerOptions = new() { Versioning = new DurableTaskWorkerOptions.VersioningOptions @@ -551,7 +545,7 @@ public void Constructor_WorkerVersioningWithoutMultiVersionRegistry_DoesNotThrow }; // Act - Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance, registry); + Action act = () => CreateWorker(new GrpcDurableTaskWorkerOptions(), workerOptions, NullLoggerFactory.Instance); // Assert act.Should().NotThrow(); @@ -568,31 +562,15 @@ static GrpcDurableTaskWorker CreateWorker( ILoggerFactory loggerFactory) { Mock factoryMock = new(MockBehavior.Strict); - return CreateWorker(grpcOptions, workerOptions, loggerFactory, factoryMock.Object, registry: null); + return CreateWorker(grpcOptions, workerOptions, loggerFactory, factoryMock.Object); } static GrpcDurableTaskWorker CreateWorker( GrpcDurableTaskWorkerOptions grpcOptions, DurableTaskWorkerOptions workerOptions, ILoggerFactory loggerFactory, - DurableTaskRegistry registry) + IDurableTaskFactory factory) { - Mock factoryMock = new(MockBehavior.Strict); - return CreateWorker(grpcOptions, workerOptions, loggerFactory, factoryMock.Object, registry); - } - - static GrpcDurableTaskWorker CreateWorker( - GrpcDurableTaskWorkerOptions grpcOptions, - DurableTaskWorkerOptions workerOptions, - ILoggerFactory loggerFactory, - IDurableTaskFactory factory, - DurableTaskRegistry? registry) - { - // The registry parameter is preserved on the helper signature for callers that build a factory - // from a registry below; the worker itself no longer reads registry contents at construction time - // since multi-version registration is just (name, version) keys in the registry it already - // consumes via factory. - _ = registry; return new GrpcDurableTaskWorker( name: "Test", factory: factory, From 985a343316a317e1dc3d3a1f5a1cc76c77e1d041 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 10:48:00 -0700 Subject: [PATCH 50/52] Apply LINQ .Where() to the two implicit-filter foreach loops in the generator github-code-quality bot flagged two foreach loops that implicitly filtered their target sequence with an inner 'if'. Rewriting the source as '.Where(...)' makes the filter intent explicit at the loop site without changing behavior. Generator tests pass unchanged (84/84). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Generators/DurableTaskSourceGenerator.cs | 21 +++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 80812b85a..a41d62981 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -258,10 +258,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) string taskVersion = string.Empty; Location? taskVersionLocation = null; bool hasWhitespaceVersion = false; - foreach (AttributeData attributeData in classType.GetAttributes()) + foreach (AttributeData attributeData in classType.GetAttributes() + .Where(a => a.AttributeClass?.ToDisplayString() == "Microsoft.DurableTask.DurableTaskVersionAttribute")) { - if (attributeData.AttributeClass?.ToDisplayString() == "Microsoft.DurableTask.DurableTaskVersionAttribute" - && attributeData.ConstructorArguments.Length > 0 + if (attributeData.ConstructorArguments.Length > 0 && attributeData.ConstructorArguments[0].Value is string version) { if (version.Length > 0 && string.IsNullOrWhiteSpace(version)) @@ -406,16 +406,13 @@ static void Execute( .Where(task => IsValidCSharpIdentifier(task.TaskName)); // Surface whitespace-only [DurableTaskVersion] as an error before we partition by name+version. - foreach (DurableTaskTypeInfo task in allTasks) + foreach (DurableTaskTypeInfo task in allTasks.Where(t => t.HasWhitespaceVersion)) { - if (task.HasWhitespaceVersion) - { - Location location = task.TaskVersionLocation ?? task.TaskNameLocation ?? Location.None; - context.ReportDiagnostic(Diagnostic.Create( - WhitespaceTaskVersionRule, - location, - task.TaskName)); - } + Location location = task.TaskVersionLocation ?? task.TaskNameLocation ?? Location.None; + context.ReportDiagnostic(Diagnostic.Create( + WhitespaceTaskVersionRule, + location, + task.TaskName)); } Dictionary standaloneOrchestratorRegistrations = new(StringComparer.OrdinalIgnoreCase); From 79d74dedb782c7c5bacb148792acabbe10133608 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 11:22:42 -0700 Subject: [PATCH 51/52] Address new PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. TaskOrchestrationContextWrapper: avoid per-call Dictionary allocation. Use the shared ImmutableDictionary.Empty singleton when the caller did not supply tags. DurableTask.Core's WithTags only requires a non-null IDictionary — the empty immutable singleton satisfies that contract without an allocation per activity call. 2. DurableTaskWorkerWorkItemFilters: Strict + empty worker version now emits a [''] version constraint instead of skipping strict-narrowing. The previous guard '!string.IsNullOrWhiteSpace(...Version)' meant Strict+Version="" silently fell back to the registry-derived versions list, which on an unversioned-only registry was [] (match all versions). That left the worker to reject mismatches after the fact instead of asking the backend to only stream matching work items. Added WorkItemFilters_StrictWithEmptyWorkerVersion_NarrowsFilterToUnversioned to lock in the new behavior. 3. Abstractions.csproj: drop the duplicate for Microsoft.DurableTask.Abstractions.Tests. src/Directory.Build.targets already auto-emits for every project, so the explicit line was producing a duplicate assembly attribute. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/Abstractions.csproj | 1 - .../Core/DurableTaskWorkerWorkItemFilters.cs | 7 +++- .../Shims/TaskOrchestrationContextWrapper.cs | 7 ++-- .../UseWorkItemFiltersTests.cs | 40 +++++++++++++++++++ 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/Abstractions/Abstractions.csproj b/src/Abstractions/Abstractions.csproj index 5fb379322..db8be76ab 100644 --- a/src/Abstractions/Abstractions.csproj +++ b/src/Abstractions/Abstractions.csproj @@ -22,7 +22,6 @@ - diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index 1caa9882a..a2de774f0 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -35,10 +35,13 @@ public class DurableTaskWorkerWorkItemFilters /// A new instance of constructed from the provided registry. internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(DurableTaskRegistry registry, DurableTaskWorkerOptions? workerOptions) { + // 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 - && !string.IsNullOrWhiteSpace(workerOptions.Versioning.Version) - ? [workerOptions.Versioning.Version] + ? [workerOptions.Versioning.Version ?? string.Empty] : null; // Orchestration filters now group registrations by logical name. Version lists are only emitted when every diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index b8ad09203..642524af6 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -160,9 +160,10 @@ static string GetRequestedActivityVersion(TaskOptions? taskOptions, string inher { string requestedVersion = GetRequestedActivityVersion(options, this.innerContext.Version); - // ScheduleTaskOptions.Builder.WithTags requires a non-null dictionary, so substitute an empty one - // when the caller did not supply tags. - IDictionary tags = options?.Tags ?? new Dictionary(StringComparer.Ordinal); + // 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 diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index d6a01fee5..6170f5401 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -264,6 +264,46 @@ public void WorkItemFilters_MixedRegistrationsWithVersioningStrict_UseConfigured 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() { From 55f23cc8ba13bbc4419066b6fe081ac821e7e022 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 13 May 2026 17:05:02 -0700 Subject: [PATCH 52/52] Sub-orchestration version selection now mirrors activity dispatch Per @bachuv on PR #695 (line 225): when a parent orchestration calls CallSubOrchestratorAsync without an explicit Version, the inherited default should be the parent's instance version (this.innerContext.Version), matching the activity-dispatch rule. Previously the sub-orchestration path read GetDefaultVersion(), which returned the worker's Versioning.DefaultVersion (intended for newly started top-level instances) or the legacy Properties["defaultVersion"] fallback. The previous behavior was asymmetric: a v2 parent would call activities on v2 by default but could spawn sub-orchestrations on a different worker-default version. The new behavior keeps a v2 parent's call tree on v2 throughout, and is consistent with the per-task versioning model. Removes the now-unused GetDefaultVersion() helper (only call site). Adds unit coverage in TaskOrchestrationContextWrapperTests: - CallSubOrchestratorAsync_PlainOptions_InheritsParentInstanceVersion - CallSubOrchestratorAsync_UnversionedParent_StampsEmptyVersion - CallSubOrchestratorAsync_ExplicitVersion_OverridesInheritedParentVersion - CallSubOrchestratorAsync_ExplicitUnversionedOption_BypassesInheritedParentVersion Existing integration tests (SubOrchestrationInheritsDefaultVersion, SubOrchestrationTaskVersionOverridesDefaultVersion) continue to pass because the parent in those scenarios is started with the same version as DefaultVersion, so innerContext.Version equals DefaultVersion and the result is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Shims/TaskOrchestrationContextWrapper.cs | 26 ++--- .../TaskOrchestrationContextWrapperTests.cs | 110 +++++++++++++++++- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index 642524af6..58c9787fe 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -221,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?.Version is { } 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 @@ -583,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/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs index c49292414..41375d272 100644 --- a/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs +++ b/test/Worker/Core.Tests/Shims/TaskOrchestrationContextWrapperTests.cs @@ -290,6 +290,94 @@ await wrapper.CallActivityAsync( 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")!; @@ -325,6 +413,10 @@ public TrackingOrchestrationContext(string? version = null) 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) @@ -340,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();