diff --git a/CHANGELOG.md b/CHANGELOG.md index a0662719..f6f5a7f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## Unreleased - +- Add support for multiple versions in DurableTask attribute ([#751](https://github.com/microsoft/durabletask-dotnet/pull/751)) ## v1.25.0-preview.2 - On-demand sandbox ([#736](https://github.com/microsoft/durabletask-dotnet/pull/736)) diff --git a/src/Abstractions/DurableTaskAttribute.cs b/src/Abstractions/DurableTaskAttribute.cs index d1ffb390..838709d6 100644 --- a/src/Abstractions/DurableTaskAttribute.cs +++ b/src/Abstractions/DurableTaskAttribute.cs @@ -34,7 +34,7 @@ public DurableTaskAttribute(string? name = null) public TaskName Name { get; } /// - /// Gets or sets the version of the durable task. Multiple classes may declare the same + /// Gets or sets the version(s) of the durable task. Multiple classes may declare the same /// as long as each declares a unique . /// /// @@ -44,6 +44,13 @@ public DurableTaskAttribute(string? name = null) /// DURABLE3005 and at registration time by the constructor. /// /// + /// A single class may declare multiple versions by supplying a comma-separated list (for example + /// "v1,v2"). Each listed version is plumbed through exactly as a single version is: the type is + /// registered under every declared version, and the source generator emits version-aware call helpers + /// for them. Empty entries (such as a trailing comma) are ignored, whitespace-only entries are rejected, + /// and duplicate entries are coalesced (case-insensitive). + /// + /// /// Entities ignore this property. /// /// diff --git a/src/Abstractions/DurableTaskRegistry.Activities.cs b/src/Abstractions/DurableTaskRegistry.Activities.cs index 3ad648b4..8afa172b 100644 --- a/src/Abstractions/DurableTaskRegistry.Activities.cs +++ b/src/Abstractions/DurableTaskRegistry.Activities.cs @@ -62,9 +62,9 @@ public DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func< public DurableTaskRegistry AddActivity(TaskName name, Type type) { Check.ConcreteType(type); - return this.AddActivity( + return this.AddActivityAllVersions( name, - type.GetDurableTaskVersion(), + type.GetDurableTaskVersions(), sp => (ITaskActivity)ActivatorUtilities.GetServiceOrCreateInstance(sp, type)); } @@ -77,9 +77,9 @@ public DurableTaskRegistry AddActivity(TaskName name, Type type) public DurableTaskRegistry AddActivity(Type type) { Check.ConcreteType(type); - return this.AddActivity( + return this.AddActivityAllVersions( type.GetTaskName(), - type.GetDurableTaskVersion(), + type.GetDurableTaskVersions(), sp => (ITaskActivity)ActivatorUtilities.GetServiceOrCreateInstance(sp, type)); } @@ -112,7 +112,7 @@ public DurableTaskRegistry AddActivity() public DurableTaskRegistry AddActivity(TaskName name, ITaskActivity activity) { Check.NotNull(activity); - return this.AddActivity(name, activity.GetType().GetDurableTaskVersion(), () => activity); + return this.AddActivityAllVersions(name, activity.GetType().GetDurableTaskVersions(), _ => activity); } /// @@ -123,10 +123,10 @@ public DurableTaskRegistry AddActivity(TaskName name, ITaskActivity activity) public DurableTaskRegistry AddActivity(ITaskActivity activity) { Check.NotNull(activity); - return this.AddActivity( + return this.AddActivityAllVersions( activity.GetType().GetTaskName(), - activity.GetType().GetDurableTaskVersion(), - () => activity); + activity.GetType().GetDurableTaskVersions(), + _ => activity); } /// diff --git a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs index 8f10a13e..f2b8d196 100644 --- a/src/Abstractions/DurableTaskRegistry.Orchestrators.cs +++ b/src/Abstractions/DurableTaskRegistry.Orchestrators.cs @@ -95,9 +95,9 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, Type type) { // TODO: Compile a constructor expression for performance. Check.ConcreteType(type); - return this.AddOrchestrator( + return this.AddOrchestratorAllVersions( name, - type.GetDurableTaskVersion(), + type.GetDurableTaskVersions(), () => (ITaskOrchestrator)Activator.CreateInstance(type)); } @@ -109,7 +109,8 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, Type type) public DurableTaskRegistry AddOrchestrator(Type type) { Check.ConcreteType(type); - return this.AddOrchestrator(type.GetTaskName(), type.GetDurableTaskVersion(), () => (ITaskOrchestrator)Activator.CreateInstance(type)); + return this.AddOrchestratorAllVersions( + type.GetTaskName(), type.GetDurableTaskVersions(), () => (ITaskOrchestrator)Activator.CreateInstance(type)); } /// @@ -140,7 +141,7 @@ public DurableTaskRegistry AddOrchestrator() public DurableTaskRegistry AddOrchestrator(TaskName name, ITaskOrchestrator orchestrator) { Check.NotNull(orchestrator); - return this.AddOrchestrator(name, orchestrator.GetType().GetDurableTaskVersion(), () => orchestrator); + return this.AddOrchestratorAllVersions(name, orchestrator.GetType().GetDurableTaskVersions(), () => orchestrator); } /// @@ -151,9 +152,9 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, ITaskOrchestrator orch public DurableTaskRegistry AddOrchestrator(ITaskOrchestrator orchestrator) { Check.NotNull(orchestrator); - return this.AddOrchestrator( + return this.AddOrchestratorAllVersions( orchestrator.GetType().GetTaskName(), - orchestrator.GetType().GetDurableTaskVersion(), + orchestrator.GetType().GetDurableTaskVersions(), () => orchestrator); } @@ -302,4 +303,20 @@ public DurableTaskRegistry AddOrchestratorFunc(TaskName name, Action + /// Registers an orchestrator factory under every supplied version. This is the shared fan-out used by the + /// type- and singleton-based registration overloads so that a class declaring multiple versions via + /// is registered once per declared version. + /// + DurableTaskRegistry AddOrchestratorAllVersions( + TaskName name, IReadOnlyList versions, Func factory) + { + foreach (TaskVersion version in versions) + { + this.AddOrchestrator(name, version, factory); + } + + return this; + } } diff --git a/src/Abstractions/DurableTaskRegistry.cs b/src/Abstractions/DurableTaskRegistry.cs index 0e8462f2..02a02c12 100644 --- a/src/Abstractions/DurableTaskRegistry.cs +++ b/src/Abstractions/DurableTaskRegistry.cs @@ -161,4 +161,20 @@ DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func + /// Registers an activity factory under every supplied version. This is the shared fan-out used by the + /// type- and singleton-based registration overloads so that a class declaring multiple versions via + /// is registered once per declared version. + /// + DurableTaskRegistry AddActivityAllVersions( + TaskName name, IReadOnlyList versions, Func factory) + { + foreach (TaskVersion version in versions) + { + this.AddActivity(name, version, factory); + } + + return this; + } } diff --git a/src/Abstractions/TypeExtensions.cs b/src/Abstractions/TypeExtensions.cs index 60e1817b..1043ad1d 100644 --- a/src/Abstractions/TypeExtensions.cs +++ b/src/Abstractions/TypeExtensions.cs @@ -29,14 +29,70 @@ public static TaskName GetTaskName(this Type type) /// /// The type to get the durable task version for. /// The durable task version. + /// + /// When the declares multiple comma-separated versions, this + /// returns only the first declared version. Prefer when all declared + /// versions are needed (for example, when registering a type under every version it supports). + /// 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(DurableTaskAttribute)) switch + IReadOnlyList versions = type.GetDurableTaskVersions(); + return versions.Count > 0 ? versions[0] : default; + } + + /// + /// Gets every durable task version declared for a type via . + /// + /// The type to get the durable task versions for. + /// + /// The distinct (case-insensitive) set of declared versions in declaration order. An unversioned type + /// (no attribute, or an empty/unset ) yields a single + /// entry so callers can always register at least once. + /// + /// + /// Thrown when any comma-separated entry is whitespace-only. This mirrors the source generator's + /// DURABLE3005 diagnostic so the reflection-based registration path fails closed for types whose + /// attribute the generator did not see. + /// + internal static IReadOnlyList GetDurableTaskVersions(this Type type) + { + // IMPORTANT: This logic needs to be kept consistent with the source generator logic. + Check.NotNull(type); + if (Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is not DurableTaskAttribute attr + || string.IsNullOrEmpty(attr.Version)) { - DurableTaskAttribute { Version: not null and not "" } attr => new TaskVersion(attr.Version), - _ => default, - }; + return new[] { TaskVersion.Unversioned }; + } + + List versions = new(); + foreach (string segment in attr.Version!.Split(',')) + { + if (segment.Length == 0) + { + // Truly-empty entry (e.g. a trailing or doubled comma). Skip silently. + continue; + } + + string trimmed = segment.Trim(); + if (trimmed.Length == 0) + { + // Whitespace-only entry. Fail closed, consistent with the TaskVersion constructor and the + // source generator's DURABLE3005 diagnostic. + throw new ArgumentException( + "A [DurableTask] Version entry must not be whitespace-only. Provide non-empty version " + + "values or omit the Version argument to declare an unversioned task.", + nameof(type)); + } + + TaskVersion version = new(trimmed); + if (!versions.Contains(version)) + { + versions.Add(version); + } + } + + return versions.Count > 0 ? versions : new[] { TaskVersion.Unversioned }; } } diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 25566bc2..24605cef 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -264,31 +264,44 @@ arg.NameEquals is null taskNameLocation = expression.GetLocation(); } - string taskVersion = string.Empty; + List taskVersions = new(); Location? taskVersionLocation = null; bool hasWhitespaceVersion = false; // Read the optional named "Version = ..." argument off the [DurableTask] attribute itself. - // Whitespace-only values are kept as empty for downstream emission so we don't generate code - // that references the offending literal; DURABLE3005 will fail the build below. + // The value may be a single version ("v1") or a comma-separated list ("v1,v2"). Each entry is + // trimmed; truly-empty entries (e.g. a trailing comma) are skipped and duplicates are coalesced. + // Whitespace-only entries are flagged so DURABLE3005 fails the build below, and the offending + // value is not emitted into generated code. AttributeArgumentSyntax? versionArg = attribute.ArgumentList?.Arguments .FirstOrDefault(arg => arg.NameEquals is { Name.Identifier.ValueText: "Version" }); if (versionArg is not null - && context.SemanticModel.GetConstantValue(versionArg.Expression).Value is string version) + && context.SemanticModel.GetConstantValue(versionArg.Expression).Value is string version + && version.Length > 0) { - if (version.Length > 0 && string.IsNullOrWhiteSpace(version)) + taskVersionLocation = versionArg.GetLocation(); + foreach (string segment in version.Split(',')) { - hasWhitespaceVersion = true; - taskVersionLocation = versionArg.GetLocation(); - taskVersion = string.Empty; - } - else - { - taskVersion = version; + if (segment.Length == 0) + { + continue; + } + + string trimmed = segment.Trim(); + if (trimmed.Length == 0) + { + hasWhitespaceVersion = true; + continue; + } + + if (!taskVersions.Contains(trimmed, StringComparer.OrdinalIgnoreCase)) + { + taskVersions.Add(trimmed); + } } } - return new DurableTaskTypeInfo(className, classNamespace, taskName, inputType, outputType, kind, taskVersion, taskNameLocation, taskVersionLocation, hasWhitespaceVersion); + return new DurableTaskTypeInfo(className, classNamespace, taskName, inputType, outputType, kind, taskVersions, taskNameLocation, taskVersionLocation, hasWhitespaceVersion); } static DurableEventTypeInfo? GetDurableEventTypeInfo(GeneratorSyntaxContext context) @@ -424,26 +437,43 @@ static void Execute( Dictionary standaloneOrchestratorRegistrations = new(StringComparer.OrdinalIgnoreCase); Dictionary standaloneActivityRegistrations = new(StringComparer.OrdinalIgnoreCase); + + // Reserves a standalone (name, version) registration key for each version the task declares. + // Returns false (after reporting DURABLE3003) when any of the task's keys is already taken, + // signaling the caller to skip the task. A task declaring multiple versions reserves one key + // per version so that two different classes cannot both claim the same name + version. + bool TryReserveStandaloneRegistration(Dictionary registrations, DurableTaskTypeInfo task) + { + foreach (string version in GetTaskVersionsOrUnversioned(task)) + { + string registrationKey = GetStandaloneTaskRegistrationKey(task.TaskName, version); + if (registrations.ContainsKey(registrationKey)) + { + Location location = task.TaskNameLocation ?? Location.None; + context.ReportDiagnostic(Diagnostic.Create( + DuplicateStandaloneOrchestratorVersionRule, + location, + task.TaskName, + version)); + return false; + } + } + + foreach (string version in GetTaskVersionsOrUnversioned(task)) + { + registrations[GetStandaloneTaskRegistrationKey(task.TaskName, version)] = task; + } + + return true; + } + foreach (DurableTaskTypeInfo task in validTasks) { if (task.IsActivity) { - if (!isDurableFunctions) + if (!isDurableFunctions && !TryReserveStandaloneRegistration(standaloneActivityRegistrations, task)) { - 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); + continue; } activities.Add(task); @@ -454,22 +484,9 @@ static void Execute( } else { - if (!isDurableFunctions) + if (!isDurableFunctions && !TryReserveStandaloneRegistration(standaloneOrchestratorRegistrations, task)) { - string registrationKey = GetStandaloneTaskRegistrationKey(task.TaskName, task.TaskVersion); - if (standaloneOrchestratorRegistrations.ContainsKey(registrationKey)) - { - Location location = task.TaskNameLocation ?? Location.None; - Diagnostic diagnostic = Diagnostic.Create( - DuplicateStandaloneOrchestratorVersionRule, - location, - task.TaskName, - task.TaskVersion); - context.ReportDiagnostic(diagnostic); - continue; - } - - standaloneOrchestratorRegistrations.Add(registrationKey, task); + continue; } orchestrators.Add(task); @@ -647,9 +664,9 @@ static void Execute( bool hasEvents = eventsInNamespace != null && eventsInNamespace.Count > 0; bool hasRegistration = isMicrosoftDurableTask && needsRegistrationMethod; bool hasVersionedStandaloneOrchestratorHelpers = !isDurableFunctions - && orchestratorsInNs.Any(task => !string.IsNullOrEmpty(task.TaskVersion)); + && orchestratorsInNs.Any(task => task.IsVersioned); bool hasVersionedStandaloneActivityHelpers = !isDurableFunctions - && activitiesInNs.Any(task => !string.IsNullOrEmpty(task.TaskVersion)); + && activitiesInNs.Any(task => task.IsVersioned); bool hasVersionedStandaloneHelpers = hasVersionedStandaloneOrchestratorHelpers || hasVersionedStandaloneActivityHelpers; if (!hasOrchestratorMethods && !hasActivityMethods && !hasEntityFunctions @@ -682,7 +699,7 @@ public static class GeneratedDurableTaskExtensions } string helperRoot = GetStandaloneHelperRoot(orchestrator, isDurableFunctions, standaloneOrchestratorCountsByTaskName); - bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(orchestrator.TaskVersion); + bool applyGeneratedVersion = !isDurableFunctions && orchestrator.IsVersioned; AddOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperRoot, applyGeneratedVersion); AddSubOrchestratorCallMethod(sourceBuilder, orchestrator, targetNamespace, helperRoot, applyGeneratedVersion); } @@ -690,7 +707,7 @@ public static class GeneratedDurableTaskExtensions foreach (DurableTaskTypeInfo activity in activitiesInNs) { string helperRoot = GetStandaloneHelperRoot(activity, isDurableFunctions, standaloneActivityCountsByTaskName); - bool applyGeneratedVersion = !isDurableFunctions && !string.IsNullOrEmpty(activity.TaskVersion); + bool applyGeneratedVersion = !isDurableFunctions && activity.IsVersioned; AddActivityCallMethod(sourceBuilder, activity, targetNamespace, helperRoot, applyGeneratedVersion); if (isDurableFunctions) @@ -828,7 +845,7 @@ static string GetStandaloneHelperRoot(DurableTaskTypeInfo task, bool isDurableFu // generated helper unique without encoding the version into the method name. Single-class and // Azure Functions cases continue to use the durable task name unchanged. if (isDurableFunctions - || string.IsNullOrEmpty(task.TaskVersion) + || !task.IsVersioned || !standaloneTaskCountsByTaskName.TryGetValue(task.TaskName, out int count) || count <= 1) { @@ -856,8 +873,33 @@ static string GetStandaloneTaskRegistrationKey(string taskName, string taskVersi return string.Concat(taskName, "\0", taskVersion); } + // Yields each declared version for the task, or a single empty (unversioned) entry when none are + // declared. Lets registration/dedup logic treat versioned and unversioned tasks uniformly. + static IEnumerable GetTaskVersionsOrUnversioned(DurableTaskTypeInfo task) + { + return task.TaskVersions.Count > 0 ? task.TaskVersions : new[] { string.Empty }; + } + static string ToCSharpStringLiteral(string value) => SymbolDisplay.FormatLiteral(value, quote: true); + // Builds a runtime guard, emitted into a multi-version call helper, that rejects a caller-supplied + // version not declared on the task. The condition compares against each declared version literal so + // the wire only ever carries a version the task advertises. + static string BuildDeclaredVersionGuard(DurableTaskTypeInfo task, string callKind) + { + string condition = string.Join( + " || ", task.TaskVersions.Select(v => $"version == {ToCSharpStringLiteral(v)}")); + string declaredVersions = string.Join(", ", task.TaskVersions); + return $@" + if (!({condition})) + {{ + throw new ArgumentException( + ""Version '"" + version + ""' is not a declared version of {callKind} '{task.TaskName}'. Declared versions: {declaredVersions}."", + nameof(version)); + }} +"; + } + static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace) { string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); @@ -874,21 +916,39 @@ static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, Dura static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace, string helperRoot, bool applyGeneratedVersion) { + bool multiVersion = applyGeneratedVersion && orchestrator.TaskVersions.Count > 1; + string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); string inputParameter = inputType + " input"; - if (inputType.EndsWith("?", StringComparison.Ordinal)) + if (!multiVersion && inputType.EndsWith("?", StringComparison.Ordinal)) { inputParameter += " = default"; } string simplifiedTypeName = SimplifyTypeName(orchestrator.TypeName, targetNamespace); - string optionsExpression = applyGeneratedVersion - ? $"ApplyGeneratedVersion(options, {ToCSharpStringLiteral(orchestrator.TaskVersion)})" - : "options"; - string versionRemarks = applyGeneratedVersion - ? $@" - /// Stamps version {orchestrator.TaskVersion} on the started instance. A non-null .Version overrides this baked version." - : string.Empty; + string versionParameter = multiVersion ? ", string version" : string.Empty; + string versionGuard = multiVersion ? BuildDeclaredVersionGuard(orchestrator, "orchestration") : string.Empty; + string optionsExpression; + string versionRemarks; + if (multiVersion) + { + string declaredVersions = string.Join(", ", orchestrator.TaskVersions); + optionsExpression = "ApplyGeneratedVersion(options, version)"; + versionRemarks = $@" + /// Stamps the supplied on the started instance; it must be one of the declared versions: {declaredVersions}. A non-null .Version overrides it. + /// The declared task version to stamp on the call. Must be one of: {declaredVersions}."; + } + else if (applyGeneratedVersion) + { + optionsExpression = $"ApplyGeneratedVersion(options, {ToCSharpStringLiteral(orchestrator.TaskVersions[0])})"; + versionRemarks = $@" + /// Stamps version {orchestrator.TaskVersions[0]} on the started instance. A non-null .Version overrides this baked version."; + } + else + { + optionsExpression = "options"; + versionRemarks = string.Empty; + } sourceBuilder.AppendLine($@" /// @@ -896,30 +956,48 @@ static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTy /// {versionRemarks} /// public static Task ScheduleNew{helperRoot}InstanceAsync( - this IOrchestrationSubmitter client, {inputParameter}, StartOrchestrationOptions? options = null) - {{ + this IOrchestrationSubmitter client, {inputParameter}{versionParameter}, StartOrchestrationOptions? options = null) + {{{versionGuard} return client.ScheduleNewOrchestrationInstanceAsync(""{orchestrator.TaskName}"", input, {optionsExpression}); }}"); } static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator, string targetNamespace, string helperRoot, bool applyGeneratedVersion) { + bool multiVersion = applyGeneratedVersion && orchestrator.TaskVersions.Count > 1; + string inputType = orchestrator.GetInputTypeForNamespace(targetNamespace); string outputType = orchestrator.GetOutputTypeForNamespace(targetNamespace); string inputParameter = inputType + " input"; - if (inputType.EndsWith("?", StringComparison.Ordinal)) + if (!multiVersion && inputType.EndsWith("?", StringComparison.Ordinal)) { inputParameter += " = default"; } string simplifiedTypeName = SimplifyTypeName(orchestrator.TypeName, targetNamespace); - string optionsExpression = applyGeneratedVersion - ? $"ApplyGeneratedVersion(options, {ToCSharpStringLiteral(orchestrator.TaskVersion)})" - : "options"; - string versionRemarks = applyGeneratedVersion - ? $@" - /// Stamps version {orchestrator.TaskVersion} on the sub-orchestration. A non-null .Version overrides this baked version." - : string.Empty; + string versionParameter = multiVersion ? ", string version" : string.Empty; + string versionGuard = multiVersion ? BuildDeclaredVersionGuard(orchestrator, "orchestration") : string.Empty; + string optionsExpression; + string versionRemarks; + if (multiVersion) + { + string declaredVersions = string.Join(", ", orchestrator.TaskVersions); + optionsExpression = "ApplyGeneratedVersion(options, version)"; + versionRemarks = $@" + /// Stamps the supplied on the sub-orchestration; it must be one of the declared versions: {declaredVersions}. A non-null .Version overrides it. + /// The declared task version to stamp on the call. Must be one of: {declaredVersions}."; + } + else if (applyGeneratedVersion) + { + optionsExpression = $"ApplyGeneratedVersion(options, {ToCSharpStringLiteral(orchestrator.TaskVersions[0])})"; + versionRemarks = $@" + /// Stamps version {orchestrator.TaskVersions[0]} on the sub-orchestration. A non-null .Version overrides this baked version."; + } + else + { + optionsExpression = "options"; + versionRemarks = string.Empty; + } sourceBuilder.AppendLine($@" /// @@ -927,8 +1005,8 @@ static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTas /// {versionRemarks} /// public static Task<{outputType}> Call{helperRoot}Async( - this TaskOrchestrationContext context, {inputParameter}, TaskOptions? options = null) - {{ + this TaskOrchestrationContext context, {inputParameter}{versionParameter}, TaskOptions? options = null) + {{{versionGuard} return context.CallSubOrchestratorAsync<{outputType}>(""{orchestrator.TaskName}"", input, {optionsExpression}); }}"); } @@ -1010,30 +1088,48 @@ static void AddStandaloneGeneratedVersionHelperMethods( static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity, string targetNamespace, string helperRoot, bool applyGeneratedVersion) { + bool multiVersion = applyGeneratedVersion && activity.TaskVersions.Count > 1; + string inputType = activity.GetInputTypeForNamespace(targetNamespace); string outputType = activity.GetOutputTypeForNamespace(targetNamespace); string inputParameter = inputType + " input"; - if (inputType.EndsWith("?", StringComparison.Ordinal)) + if (!multiVersion && inputType.EndsWith("?", StringComparison.Ordinal)) { inputParameter += " = default"; } string simplifiedTypeName = SimplifyTypeName(activity.TypeName, targetNamespace); - string optionsExpression = applyGeneratedVersion - ? $"ApplyGeneratedActivityVersion(options, {ToCSharpStringLiteral(activity.TaskVersion)})" - : "options"; - string versionRemarks = applyGeneratedVersion - ? $@" - /// Stamps version {activity.TaskVersion} on the activity call. A non-null .Version overrides this baked version." - : string.Empty; + string versionParameter = multiVersion ? ", string version" : string.Empty; + string versionGuard = multiVersion ? BuildDeclaredVersionGuard(activity, "activity") : string.Empty; + string optionsExpression; + string versionRemarks; + if (multiVersion) + { + string declaredVersions = string.Join(", ", activity.TaskVersions); + optionsExpression = "ApplyGeneratedActivityVersion(options, version)"; + versionRemarks = $@" + /// Stamps the supplied on the activity call; it must be one of the declared versions: {declaredVersions}. A non-null .Version overrides it. + /// The declared task version to stamp on the call. Must be one of: {declaredVersions}."; + } + else if (applyGeneratedVersion) + { + optionsExpression = $"ApplyGeneratedActivityVersion(options, {ToCSharpStringLiteral(activity.TaskVersions[0])})"; + versionRemarks = $@" + /// Stamps version {activity.TaskVersions[0]} on the activity call. A non-null .Version overrides this baked version."; + } + else + { + optionsExpression = "options"; + versionRemarks = string.Empty; + } sourceBuilder.AppendLine($@" /// /// Calls the activity. /// {versionRemarks} /// - public static Task<{outputType}> Call{helperRoot}Async(this TaskOrchestrationContext ctx, {inputParameter}, TaskOptions? options = null) - {{ + public static Task<{outputType}> Call{helperRoot}Async(this TaskOrchestrationContext ctx, {inputParameter}{versionParameter}, TaskOptions? options = null) + {{{versionGuard} return ctx.CallActivityAsync<{outputType}>(""{activity.TaskName}"", input, {optionsExpression}); }}"); } @@ -1210,7 +1306,7 @@ public DurableTaskTypeInfo( ITypeSymbol? inputType, ITypeSymbol? outputType, DurableTaskKind kind, - string taskVersion, + IReadOnlyList taskVersions, Location? taskNameLocation = null, Location? taskVersionLocation = null, bool hasWhitespaceVersion = false) @@ -1219,7 +1315,7 @@ public DurableTaskTypeInfo( this.Namespace = taskNamespace; this.TaskName = taskName; this.Kind = kind; - this.TaskVersion = taskVersion; + this.TaskVersions = taskVersions; this.TaskNameLocation = taskNameLocation; this.TaskVersionLocation = taskVersionLocation; this.HasWhitespaceVersion = hasWhitespaceVersion; @@ -1230,7 +1326,17 @@ public DurableTaskTypeInfo( public string TypeName { get; } public string Namespace { get; } public string TaskName { get; } - public string TaskVersion { get; } + + /// + /// Gets the distinct versions declared on the task in declaration order. Empty for an unversioned task. + /// + public IReadOnlyList TaskVersions { get; } + + /// + /// Gets a value indicating whether the task declares one or more versions. + /// + public bool IsVersioned => this.TaskVersions.Count > 0; + public DurableTaskKind Kind { get; } public Location? TaskNameLocation { get; } public Location? TaskVersionLocation { get; } diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index ccedc3b1..9d331bc8 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.DurableTask.Abstractions; + namespace Microsoft.DurableTask.Worker; /// @@ -57,7 +59,9 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable .GroupBy(orchestration => orchestration.Key.Name, StringComparer.OrdinalIgnoreCase) .Select(group => { - IReadOnlyList versions = strictWorkerVersions ?? GetFilterVersions(group.Select(entry => entry.Key.Version)); + IReadOnlyList versions = strictWorkerVersions ?? GetFilterVersions( + group.Select(entry => entry.Key.Version), + workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.CurrentOrOlder ? workerOptions.Versioning.Version : null); return new OrchestrationFilter { @@ -71,7 +75,9 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable .GroupBy(activity => activity.Key.Name, StringComparer.OrdinalIgnoreCase) .Select(group => { - IReadOnlyList versions = strictWorkerVersions ?? GetFilterVersions(group.Select(entry => entry.Key.Version)); + IReadOnlyList versions = strictWorkerVersions ?? GetFilterVersions( + group.Select(entry => entry.Key.Version), + workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.CurrentOrOlder ? workerOptions.Versioning.Version : null); return new ActivityFilter { @@ -92,13 +98,14 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable }).ToList(), }; - static IReadOnlyList GetFilterVersions(IEnumerable versions) + static IReadOnlyList GetFilterVersions(IEnumerable versions, string? workerVersion) { // Normalize null to "" so an unversioned registration appears consistently. string[] normalized = versions .Select(version => version ?? string.Empty) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) + .Where(version => workerVersion == null || TaskOrchestrationVersioningUtils.CompareVersions(workerVersion, version) >= 0) .ToArray(); // Unversioned-only: emit the wildcard match-all (empty list) so the backend can deliver diff --git a/test/Abstractions.Tests/DurableTaskAttributeVersionTests.cs b/test/Abstractions.Tests/DurableTaskAttributeVersionTests.cs index edbb541e..30e1816d 100644 --- a/test/Abstractions.Tests/DurableTaskAttributeVersionTests.cs +++ b/test/Abstractions.Tests/DurableTaskAttributeVersionTests.cs @@ -59,6 +59,61 @@ public void GetDurableTaskVersion_WhitespaceVersion_ThrowsArgumentException() .WithMessage("*whitespace*"); } + [Fact] + public void GetDurableTaskVersions_MultipleVersions_ReturnsEachInOrder() + { + // Arrange + Type type = typeof(MultiVersionTestOrchestrator); + + // Act + IReadOnlyList versions = type.GetDurableTaskVersions(); + + // Assert + versions.Select(v => v.Version).Should().Equal("v1", "v2", "v3"); + } + + [Fact] + public void GetDurableTaskVersions_WithWhitespaceAndDuplicateEntries_TrimsSkipsAndDedups() + { + // Arrange + Type type = typeof(MessyMultiVersionTestOrchestrator); + + // Act + IReadOnlyList versions = type.GetDurableTaskVersions(); + + // Assert — surrounding whitespace is trimmed, the trailing-comma empty entry is dropped, and the + // duplicate "v1" is coalesced (case-insensitively). + versions.Select(v => v.Version).Should().Equal("v1", "v2"); + } + + [Fact] + public void GetDurableTaskVersions_Unversioned_ReturnsSingleUnversionedEntry() + { + // Arrange + Type type = typeof(UnversionedTestOrchestrator); + + // Act + IReadOnlyList versions = type.GetDurableTaskVersions(); + + // Assert + versions.Should().ContainSingle().Which.Should().Be(TaskVersion.Unversioned); + } + + [Fact] + public void GetDurableTaskVersions_WhitespaceOnlyEntry_ThrowsArgumentException() + { + // Arrange — a whitespace-only entry between commas must fail closed, mirroring the source + // generator's DURABLE3005 diagnostic. + Type type = typeof(WhitespaceEntryMultiVersionTestOrchestrator); + + // Act + Action act = () => type.GetDurableTaskVersions(); + + // Assert + act.Should().ThrowExactly() + .WithMessage("*whitespace*"); + } + [DurableTask(Version = "v1")] sealed class VersionedTestOrchestrator : TaskOrchestrator { @@ -78,4 +133,25 @@ sealed class WhitespaceVersionedTestOrchestrator : TaskOrchestrator RunAsync(TaskOrchestrationContext context, string input) => Task.FromResult(input); } + + [DurableTask("MultiVersion", Version = "v1,v2,v3")] + sealed class MultiVersionTestOrchestrator : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult(input); + } + + [DurableTask("MessyMultiVersion", Version = " v1 , v2 ,v1,")] + sealed class MessyMultiVersionTestOrchestrator : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult(input); + } + + [DurableTask("WhitespaceEntryMultiVersion", Version = "v1, ,v2")] + sealed class WhitespaceEntryMultiVersionTestOrchestrator : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult(input); + } } diff --git a/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs b/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs index ba8a0fe0..5b90597f 100644 --- a/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs +++ b/test/Abstractions.Tests/DurableTaskRegistryVersioningTests.cs @@ -226,6 +226,54 @@ public void AddActivityFunc_ExplicitVersion_SameLogicalNameAndVersion_Throws() act.Should().ThrowExactly().WithParameterName("name"); } + [Fact] + public void AddOrchestrator_MultiVersionAttribute_RegistersUnderEachVersion() + { + // Arrange + DurableTaskRegistry registry = new(); + + // Act + registry.AddOrchestrator(); + + // Assert — a single class declaring "v1,v2" is registered once per declared version. + registry.OrchestratorsByVersion.Keys.Should().BeEquivalentTo(new[] + { + new TaskVersionKey("MultiVersionWorkflow", "v1"), + new TaskVersionKey("MultiVersionWorkflow", "v2"), + }); + } + + [Fact] + public void AddActivity_MultiVersionAttribute_RegistersUnderEachVersion() + { + // Arrange + DurableTaskRegistry registry = new(); + + // Act + registry.AddActivity(); + + // Assert + registry.ActivitiesByVersion.Keys.Should().BeEquivalentTo(new[] + { + new TaskVersionKey("MultiVersionActivity", "v1"), + new TaskVersionKey("MultiVersionActivity", "v2"), + }); + } + + [Fact] + public void AddOrchestrator_MultiVersionAttribute_CollidingVersionWithExistingRegistration_Throws() + { + // Arrange — "v2" is shared between the multi-version class and the single-version class. + DurableTaskRegistry registry = new(); + registry.AddOrchestrator("MultiVersionWorkflow", new TaskVersion("v2"), () => Mock.Of()); + + // Act + Action act = () => registry.AddOrchestrator(); + + // Assert + act.Should().ThrowExactly().WithParameterName("name"); + } + [DurableTask("ShippingWorkflow", Version = "v1")] sealed class ShippingWorkflowV1 : TaskOrchestrator { @@ -307,4 +355,18 @@ public ManualActivity(string marker) public override Task RunAsync(TaskActivityContext context, string input) => Task.FromResult(this.marker); } + + [DurableTask("MultiVersionWorkflow", Version = "v1,v2")] + sealed class MultiVersionWorkflow : TaskOrchestrator + { + public override Task RunAsync(TaskOrchestrationContext context, string input) + => Task.FromResult(input); + } + + [DurableTask("MultiVersionActivity", Version = "v1,v2")] + sealed class MultiVersionActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult(input); + } } diff --git a/test/Generators.Tests/VersionedActivityTests.cs b/test/Generators.Tests/VersionedActivityTests.cs index 4c067312..ab58df40 100644 --- a/test/Generators.Tests/VersionedActivityTests.cs +++ b/test/Generators.Tests/VersionedActivityTests.cs @@ -71,6 +71,70 @@ internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistr isDurableFunctions: false); } + [Fact] + public Task Standalone_MultiVersionedActivity_GeneratesVersionParameterHelper() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""ProcessInvoice"", Version = ""v1,v2"")] +class ProcessInvoice : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Calls the activity. +/// +/// Stamps the supplied on the activity call; it must be one of the declared versions: v1, v2. A non-null .Version overrides it. +/// The declared task version to stamp on the call. Must be one of: v1, v2. +/// +public static Task CallProcessInvoiceAsync(this TaskOrchestrationContext ctx, int input, string version, TaskOptions? options = null) +{ + if (!(version == ""v1"" || version == ""v2"")) + { + throw new ArgumentException( + ""Version '"" + version + ""' is not a declared version of activity 'ProcessInvoice'. Declared versions: v1, v2."", + nameof(version)); + } + + return ctx.CallActivityAsync(""ProcessInvoice"", input, ApplyGeneratedActivityVersion(options, version)); +} + +static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new TaskOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : new TaskOptions(options) { Version = version }; +} + +internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) +{ + builder.AddActivity(); + return builder; +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + [Fact] public Task Standalone_MultiVersionedActivities_GenerateVersionQualifiedHelpersOnly() { diff --git a/test/Generators.Tests/VersionedOrchestratorTests.cs b/test/Generators.Tests/VersionedOrchestratorTests.cs index 2c1099e8..0c2d3f48 100644 --- a/test/Generators.Tests/VersionedOrchestratorTests.cs +++ b/test/Generators.Tests/VersionedOrchestratorTests.cs @@ -95,6 +95,117 @@ public static Task CallInvoiceWorkflowAsync( : 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_MultiVersionedOrchestrator_GeneratesVersionParameterHelpers() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableTask(""InvoiceWorkflow"", Version = ""v1,v2"")] +class InvoiceWorkflow : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) => Task.FromResult(string.Empty); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// +/// Stamps the supplied on the started instance; it must be one of the declared versions: v1, v2. A non-null .Version overrides it. +/// The declared task version to stamp on the call. Must be one of: v1, v2. +/// +public static Task ScheduleNewInvoiceWorkflowInstanceAsync( + this IOrchestrationSubmitter client, int input, string version, StartOrchestrationOptions? options = null) +{ + if (!(version == ""v1"" || version == ""v2"")) + { + throw new ArgumentException( + ""Version '"" + version + ""' is not a declared version of orchestration 'InvoiceWorkflow'. Declared versions: v1, v2."", + nameof(version)); + } + + return client.ScheduleNewOrchestrationInstanceAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, version)); +} + +/// +/// Calls the sub-orchestrator. +/// +/// Stamps the supplied on the sub-orchestration; it must be one of the declared versions: v1, v2. A non-null .Version overrides it. +/// The declared task version to stamp on the call. Must be one of: v1, v2. +/// +public static Task CallInvoiceWorkflowAsync( + this TaskOrchestrationContext context, int input, string version, TaskOptions? options = null) +{ + if (!(version == ""v1"" || version == ""v2"")) + { + throw new ArgumentException( + ""Version '"" + version + ""' is not a declared version of orchestration 'InvoiceWorkflow'. Declared versions: v1, v2."", + nameof(version)); + } + + return context.CallSubOrchestratorAsync(""InvoiceWorkflow"", input, ApplyGeneratedVersion(options, version)); +} + +static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is null) + { + return new StartOrchestrationOptions + { + Version = version, + }; + } + + if (options.Version is not null) + { + return options; + } + + return options with { Version = new TaskVersion(version) }; +} + +static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version) +{ + // Caller-supplied options.Version is preserved as-is — the explicit value wins. Otherwise we + // stamp the version that the generated helper was emitted for. + if (options is SubOrchestrationOptions subOrchestrationOptions) + { + return subOrchestrationOptions.Version is not null + ? subOrchestrationOptions + : subOrchestrationOptions with { Version = new TaskVersion(version) }; + } + + if (options is null) + { + return new SubOrchestrationOptions + { + Version = version, + }; + } + + return options.Version is not null + ? options + : new SubOrchestrationOptions(options) { Version = version }; +} + internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistry builder) { builder.AddOrchestrator(); diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index 6ad2eac8..4455bd96 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -155,6 +155,133 @@ public void WorkItemFilters_DefaultNullWithVersioningCurrentOrOlder_WhenExplicit actual.Activities.Should().ContainSingle(a => a.Name == nameof(TestActivity) && a.Versions.Count == 0); } + [Fact] + public void WorkItemFilters_CurrentOrOlder_FiltersOutNewerThanWorkerVersion() + { + // Arrange — register v1 and v2 of the same logical name. With CurrentOrOlder at worker version + // "v1", the backend should only be asked for versions <= v1 so it never streams v2 work items + // the worker would reject after the fact. + 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 = "v1", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.CurrentOrOlder, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert — v2 is dropped because it is newer than the worker version. + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["v1"]); + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Name.Should().Be("FilterActivity"); + actual.Activities[0].Versions.Should().BeEquivalentTo(["v1"]); + } + + [Fact] + public void WorkItemFilters_CurrentOrOlder_KeepsVersionsEqualToOrOlderThanWorkerVersion() + { + // Arrange — register v1 and v2 with CurrentOrOlder at worker version "v2". Both are <= v2, so + // both should survive the filter. + 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 = "v2", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.CurrentOrOlder, + }; + }); + 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"]); + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Name.Should().Be("FilterActivity"); + actual.Activities[0].Versions.Should().BeEquivalentTo(["v1", "v2"]); + } + + [Fact] + public void WorkItemFilters_CurrentOrOlder_RetainsUnversionedAlongsideOlderVersions() + { + // Arrange — register an unversioned, v1, and v2 registration under the same logical name with + // CurrentOrOlder at worker version "v1". The unversioned ("") entry is always older than any + // defined worker version, so it stays as a fallback; v2 is dropped as newer. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddActivity(); + registry.AddActivity(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "v1", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.CurrentOrOlder, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert — "" (unversioned) and v1 are retained, v2 is filtered out. + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo([string.Empty, "v1"]); + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Name.Should().Be("FilterActivity"); + actual.Activities[0].Versions.Should().BeEquivalentTo([string.Empty, "v1"]); + } + [Fact] public void WorkItemFilters_DefaultNullWithVersioningNone_WhenExplicitlyOptedIn() { diff --git a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs index 5244607e..b217d19a 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -153,6 +153,45 @@ public void PublicTryCreateOrchestrator_UsesUnversionedRegistrationOnly() orchestrator.Should().BeOfType(); } + [Fact] + public void TryCreateActivity_UnversionedAndMultiVersionRegistrations_ServesEveryDeclaredVersion() + { + // Arrange — an "original" unversioned activity registered with a bare [DurableTask] attribute + // coexists with a newer class that declares multiple versions in one attribute + // ([DurableTask("InvoiceActivity", Version = "1.0.0,1.1.0")]). All three logical endpoints — + // the unversioned original plus each comma-separated version — must be independently servable. + DurableTaskRegistry registry = new(); + registry.AddActivity(); + registry.AddActivity(); + IVersionedTaskFactory factory = (IVersionedTaskFactory)registry.BuildFactory(); + + // Act + bool unversionedFound = factory.TryCreateActivity( + new TaskName("InvoiceActivity"), + default, + Mock.Of(), + out ITaskActivity? unversionedActivity); + bool v1Found = factory.TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("1.0.0"), + Mock.Of(), + out ITaskActivity? v1Activity); + bool v11Found = factory.TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("1.1.0"), + Mock.Of(), + out ITaskActivity? v11Activity); + + // Assert — the unversioned request resolves to the original, and each declared version resolves + // to the multi-version class. + unversionedFound.Should().BeTrue(); + unversionedActivity.Should().BeOfType(); + v1Found.Should().BeTrue(); + v1Activity.Should().BeOfType(); + v11Found.Should().BeTrue(); + v11Activity.Should().BeOfType(); + } + [DurableTask("InvoiceWorkflow", Version = "v1")] sealed class InvoiceWorkflowV1 : TaskOrchestrator { @@ -173,4 +212,18 @@ sealed class UnversionedInvoiceWorkflow : TaskOrchestrator public override Task RunAsync(TaskOrchestrationContext context, string input) => Task.FromResult("unversioned"); } + + [DurableTask("InvoiceActivity")] + sealed class OriginalInvoiceActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult("original"); + } + + [DurableTask("InvoiceActivity", Version = "1.0.0,1.1.0")] + sealed class MultiVersionInvoiceActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult(context.Version); + } }