Skip to content

Commit dd42561

Browse files
halspangCopilot
andcommitted
Add multi-version support to [DurableTask] annotations
The Version property now accepts a comma-separated list (e.g. "v1,v2"), registering the type under each declared version and emitting call helpers that take a required 'version' parameter validated against the declared set. Single-version and unversioned behavior is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3e92eb6 commit dd42561

14 files changed

Lines changed: 806 additions & 104 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Changelog
22

33
## Unreleased
4-
4+
- Add support for multiple versions in DurableTask attribute ([#751](https://github.com/microsoft/durabletask-dotnet/pull/751))
55

66
## v1.25.0-preview.2
77
- On-demand sandbox ([#736](https://github.com/microsoft/durabletask-dotnet/pull/736))

src/Abstractions/DurableTaskAttribute.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public DurableTaskAttribute(string? name = null)
3434
public TaskName Name { get; }
3535

3636
/// <summary>
37-
/// Gets or sets the version of the durable task. Multiple classes may declare the same
37+
/// Gets or sets the version(s) of the durable task. Multiple classes may declare the same
3838
/// <see cref="Name"/> as long as each declares a unique <see cref="Version"/>.
3939
/// </summary>
4040
/// <remarks>
@@ -44,6 +44,13 @@ public DurableTaskAttribute(string? name = null)
4444
/// <c>DURABLE3005</c> and at registration time by the <see cref="TaskVersion"/> constructor.
4545
/// </para>
4646
/// <para>
47+
/// A single class may declare multiple versions by supplying a comma-separated list (for example
48+
/// <c>"v1,v2"</c>). Each listed version is plumbed through exactly as a single version is: the type is
49+
/// registered under every declared version, and the source generator emits version-aware call helpers
50+
/// for them. Empty entries (such as a trailing comma) are ignored, whitespace-only entries are rejected,
51+
/// and duplicate entries are coalesced (case-insensitive).
52+
/// </para>
53+
/// <para>
4754
/// Entities ignore this property.
4855
/// </para>
4956
/// </remarks>

src/Abstractions/DurableTaskRegistry.Activities.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ public DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func<
6262
public DurableTaskRegistry AddActivity(TaskName name, Type type)
6363
{
6464
Check.ConcreteType<ITaskActivity>(type);
65-
return this.AddActivity(
65+
return this.AddActivityAllVersions(
6666
name,
67-
type.GetDurableTaskVersion(),
67+
type.GetDurableTaskVersions(),
6868
sp => (ITaskActivity)ActivatorUtilities.GetServiceOrCreateInstance(sp, type));
6969
}
7070

@@ -77,9 +77,9 @@ public DurableTaskRegistry AddActivity(TaskName name, Type type)
7777
public DurableTaskRegistry AddActivity(Type type)
7878
{
7979
Check.ConcreteType<ITaskActivity>(type);
80-
return this.AddActivity(
80+
return this.AddActivityAllVersions(
8181
type.GetTaskName(),
82-
type.GetDurableTaskVersion(),
82+
type.GetDurableTaskVersions(),
8383
sp => (ITaskActivity)ActivatorUtilities.GetServiceOrCreateInstance(sp, type));
8484
}
8585

@@ -112,7 +112,7 @@ public DurableTaskRegistry AddActivity<TActivity>()
112112
public DurableTaskRegistry AddActivity(TaskName name, ITaskActivity activity)
113113
{
114114
Check.NotNull(activity);
115-
return this.AddActivity(name, activity.GetType().GetDurableTaskVersion(), () => activity);
115+
return this.AddActivityAllVersions(name, activity.GetType().GetDurableTaskVersions(), _ => activity);
116116
}
117117

118118
/// <summary>
@@ -123,10 +123,10 @@ public DurableTaskRegistry AddActivity(TaskName name, ITaskActivity activity)
123123
public DurableTaskRegistry AddActivity(ITaskActivity activity)
124124
{
125125
Check.NotNull(activity);
126-
return this.AddActivity(
126+
return this.AddActivityAllVersions(
127127
activity.GetType().GetTaskName(),
128-
activity.GetType().GetDurableTaskVersion(),
129-
() => activity);
128+
activity.GetType().GetDurableTaskVersions(),
129+
_ => activity);
130130
}
131131

132132
/// <summary>

src/Abstractions/DurableTaskRegistry.Orchestrators.cs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, Type type)
9595
{
9696
// TODO: Compile a constructor expression for performance.
9797
Check.ConcreteType<ITaskOrchestrator>(type);
98-
return this.AddOrchestrator(
98+
return this.AddOrchestratorAllVersions(
9999
name,
100-
type.GetDurableTaskVersion(),
100+
type.GetDurableTaskVersions(),
101101
() => (ITaskOrchestrator)Activator.CreateInstance(type));
102102
}
103103

@@ -109,7 +109,8 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, Type type)
109109
public DurableTaskRegistry AddOrchestrator(Type type)
110110
{
111111
Check.ConcreteType<ITaskOrchestrator>(type);
112-
return this.AddOrchestrator(type.GetTaskName(), type.GetDurableTaskVersion(), () => (ITaskOrchestrator)Activator.CreateInstance(type));
112+
return this.AddOrchestratorAllVersions(
113+
type.GetTaskName(), type.GetDurableTaskVersions(), () => (ITaskOrchestrator)Activator.CreateInstance(type));
113114
}
114115

115116
/// <summary>
@@ -140,7 +141,7 @@ public DurableTaskRegistry AddOrchestrator<TOrchestrator>()
140141
public DurableTaskRegistry AddOrchestrator(TaskName name, ITaskOrchestrator orchestrator)
141142
{
142143
Check.NotNull(orchestrator);
143-
return this.AddOrchestrator(name, orchestrator.GetType().GetDurableTaskVersion(), () => orchestrator);
144+
return this.AddOrchestratorAllVersions(name, orchestrator.GetType().GetDurableTaskVersions(), () => orchestrator);
144145
}
145146

146147
/// <summary>
@@ -151,9 +152,9 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, ITaskOrchestrator orch
151152
public DurableTaskRegistry AddOrchestrator(ITaskOrchestrator orchestrator)
152153
{
153154
Check.NotNull(orchestrator);
154-
return this.AddOrchestrator(
155+
return this.AddOrchestratorAllVersions(
155156
orchestrator.GetType().GetTaskName(),
156-
orchestrator.GetType().GetDurableTaskVersion(),
157+
orchestrator.GetType().GetDurableTaskVersions(),
157158
() => orchestrator);
158159
}
159160

@@ -302,4 +303,20 @@ public DurableTaskRegistry AddOrchestratorFunc(TaskName name, Action<TaskOrchest
302303
return CompletedNullTask;
303304
});
304305
}
306+
307+
/// <summary>
308+
/// Registers an orchestrator factory under every supplied version. This is the shared fan-out used by the
309+
/// type- and singleton-based registration overloads so that a class declaring multiple versions via
310+
/// <see cref="DurableTaskAttribute.Version"/> is registered once per declared version.
311+
/// </summary>
312+
DurableTaskRegistry AddOrchestratorAllVersions(
313+
TaskName name, IReadOnlyList<TaskVersion> versions, Func<ITaskOrchestrator> factory)
314+
{
315+
foreach (TaskVersion version in versions)
316+
{
317+
this.AddOrchestrator(name, version, factory);
318+
}
319+
320+
return this;
321+
}
305322
}

src/Abstractions/DurableTaskRegistry.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,20 @@ DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func<IServic
161161
this.ActivitiesByVersion.Add(key, factory);
162162
return this;
163163
}
164+
165+
/// <summary>
166+
/// Registers an activity factory under every supplied version. This is the shared fan-out used by the
167+
/// type- and singleton-based registration overloads so that a class declaring multiple versions via
168+
/// <see cref="DurableTaskAttribute.Version"/> is registered once per declared version.
169+
/// </summary>
170+
DurableTaskRegistry AddActivityAllVersions(
171+
TaskName name, IReadOnlyList<TaskVersion> versions, Func<IServiceProvider, ITaskActivity> factory)
172+
{
173+
foreach (TaskVersion version in versions)
174+
{
175+
this.AddActivity(name, version, factory);
176+
}
177+
178+
return this;
179+
}
164180
}

src/Abstractions/TypeExtensions.cs

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,70 @@ public static TaskName GetTaskName(this Type type)
2929
/// </summary>
3030
/// <param name="type">The type to get the durable task version for.</param>
3131
/// <returns>The durable task version.</returns>
32+
/// <remarks>
33+
/// When the <see cref="DurableTaskAttribute.Version"/> declares multiple comma-separated versions, this
34+
/// returns only the first declared version. Prefer <see cref="GetDurableTaskVersions"/> when all declared
35+
/// versions are needed (for example, when registering a type under every version it supports).
36+
/// </remarks>
3237
internal static TaskVersion GetDurableTaskVersion(this Type type)
3338
{
3439
// IMPORTANT: This logic needs to be kept consistent with the source generator logic.
3540
Check.NotNull(type);
36-
return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) switch
41+
IReadOnlyList<TaskVersion> versions = type.GetDurableTaskVersions();
42+
return versions.Count > 0 ? versions[0] : default;
43+
}
44+
45+
/// <summary>
46+
/// Gets every durable task version declared for a type via <see cref="DurableTaskAttribute.Version"/>.
47+
/// </summary>
48+
/// <param name="type">The type to get the durable task versions for.</param>
49+
/// <returns>
50+
/// The distinct (case-insensitive) set of declared versions in declaration order. An unversioned type
51+
/// (no attribute, or an empty/unset <see cref="DurableTaskAttribute.Version"/>) yields a single
52+
/// <see cref="TaskVersion.Unversioned"/> entry so callers can always register at least once.
53+
/// </returns>
54+
/// <exception cref="ArgumentException">
55+
/// Thrown when any comma-separated entry is whitespace-only. This mirrors the source generator's
56+
/// <c>DURABLE3005</c> diagnostic so the reflection-based registration path fails closed for types whose
57+
/// attribute the generator did not see.
58+
/// </exception>
59+
internal static IReadOnlyList<TaskVersion> GetDurableTaskVersions(this Type type)
60+
{
61+
// IMPORTANT: This logic needs to be kept consistent with the source generator logic.
62+
Check.NotNull(type);
63+
if (Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is not DurableTaskAttribute attr
64+
|| string.IsNullOrEmpty(attr.Version))
3765
{
38-
DurableTaskAttribute { Version: not null and not "" } attr => new TaskVersion(attr.Version),
39-
_ => default,
40-
};
66+
return new[] { TaskVersion.Unversioned };
67+
}
68+
69+
List<TaskVersion> versions = new();
70+
foreach (string segment in attr.Version!.Split(','))
71+
{
72+
if (segment.Length == 0)
73+
{
74+
// Truly-empty entry (e.g. a trailing or doubled comma). Skip silently.
75+
continue;
76+
}
77+
78+
string trimmed = segment.Trim();
79+
if (trimmed.Length == 0)
80+
{
81+
// Whitespace-only entry. Fail closed, consistent with the TaskVersion constructor and the
82+
// source generator's DURABLE3005 diagnostic.
83+
throw new ArgumentException(
84+
"A [DurableTask] Version entry must not be whitespace-only. Provide non-empty version " +
85+
"values or omit the Version argument to declare an unversioned task.",
86+
nameof(type));
87+
}
88+
89+
TaskVersion version = new(trimmed);
90+
if (!versions.Contains(version))
91+
{
92+
versions.Add(version);
93+
}
94+
}
95+
96+
return versions.Count > 0 ? versions : new[] { TaskVersion.Unversioned };
4197
}
4298
}

0 commit comments

Comments
 (0)