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);
+ }
}