From 0fc9c08afccaf644af511024a0254e7ea84db4cc Mon Sep 17 00:00:00 2001 From: torosent Date: Tue, 31 Mar 2026 15:28:33 -0700 Subject: [PATCH 01/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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