Skip to content

Commit bd4b8db

Browse files
torosentCopilot
andcommitted
Address re-review findings #1-4, 7-8: explicit-unversioned helper conflict, TaskVersion null-safety, integration test refresh, migration recipe
Closes the second-pass review findings from PR #695: #1 — Stale tag references in integration tests: VersionedClassSyntaxTestOrchestration.cs and VersionedClassSyntaxIntegrationTests.cs were still using the pre-rename 'microsoft.durabletask.activity.explicit-version' constant. The spoof-protection test in particular was passing for the wrong reason since the SDK no longer recognizes that key. Updated both files to use ActivityVersioning.VersionSourceTagName / ExplicitSource / InheritedSource and added IVT for Worker.Grpc.IntegrationTests in Worker.csproj so the integration tests can reference the SDK constants directly. #2 — Generator helper conflict check missed explicit-unversioned: ApplyGeneratedVersion (StartOrchestrationOptions / SubOrchestrationOptions) and ApplyGeneratedActivityVersion all used patterns that required '.Version.Length > 0' or '!IsNullOrWhiteSpace', which excluded TaskVersion.Unversioned. A user calling CallProcessPayment_2Async(..., new ActivityOptions { Version = TaskVersion.Unversioned }) silently got v2 instead of the contradicting throw the helper was supposed to enforce. Now the patterns match any non-null Version (including the empty/unversioned case) and the diagnostic message distinguishes 'explicit-unversioned' from a specific version. Generator-output snapshot tests updated. #3 — TaskVersion null-storage caused null/empty mismatch and GetHashCode crash: the constructor stored 'null' verbatim when given null. Effects: TaskVersion.Unversioned.Equals(new TaskVersion('')) was false, and TaskVersion.Unversioned.GetHashCode() called StringComparer.OrdinalIgnoreCase.GetHashCode(null) which throws — making any user who put TaskVersion in a dictionary key crash at runtime. Constructor now normalizes null/empty to string.Empty; Equals and GetHashCode are null-safe and treat null and empty as identical. (default(TaskVersion) still has Version=null at the field level — that's a struct-default constraint — but Equals/GetHashCode handle it.) #4 — Whitespace TaskVersion ctor validation: the registry rejected whitespace-only TaskVersion, but new TaskVersion(' ') itself accepted the value, so it could leak into ActivityOptions / StartOrchestrationOptions / SubOrchestrationOptions and route silently to 'no exact match'. The constructor now throws ArgumentException for non-empty whitespace. The redundant ValidateRegistrationVersion method and the explicit whitespace check in DurableTaskVersionAttribute were removed because TaskVersion's constructor handles them centrally. #7 — Migration recipe: added a new section to the proposal documenting the 'add [DurableTaskVersion] to an existing class' migration path. Recommended recipe is keep the unversioned class registered until in-flight unversioned instances drain or ContinueAsNew to the new version. Reverse migration (removing [DurableTaskVersion]) is documented as unsupported. #8 — Tag rename pre-release note: added a one-paragraph note in the proposal acknowledging the in-flight rename from 'explicit-version' (boolean) to 'version-source' ('explicit'/'inherited') and recommending pre-release deployments drain before pointing at the new contract since the worker now fail-closes on missing tag for versioned activity requests. Tests: 84 generator + 127 worker.tests + 135 worker.grpc.tests + 159 abstractions.tests + 8 integration = 513 total, all passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e4981d0 commit bd4b8db

10 files changed

Lines changed: 172 additions & 97 deletions

src/Abstractions/DurableTaskRegistry.Orchestrators.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ public DurableTaskRegistry AddOrchestrator(TaskName name, TaskVersion version, F
5454
{
5555
Check.NotDefault(name);
5656
Check.NotNull(factory);
57-
ValidateRegistrationVersion(version);
5857

5958
OrchestratorVersionKey key = new(name, version);
6059
if (this.Orchestrators.ContainsKey(key))

src/Abstractions/DurableTaskRegistry.cs

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func<IServic
7979
{
8080
Check.NotDefault(name);
8181
Check.NotNull(factory);
82-
ValidateRegistrationVersion(version);
8382

8483
ActivityVersionKey key = new(name, version);
8584
if (this.Activities.ContainsKey(key))
@@ -93,18 +92,4 @@ DurableTaskRegistry AddActivity(TaskName name, TaskVersion version, Func<IServic
9392
this.Activities.Add(key, factory);
9493
return this;
9594
}
96-
97-
static void ValidateRegistrationVersion(TaskVersion version)
98-
{
99-
// An empty/null version is the unversioned/default registration. Whitespace-only is rejected because
100-
// it would be silently coerced to "no version" by some code paths and to a non-empty version by others,
101-
// making the registration unreachable.
102-
string? value = version.Version;
103-
if (value is not null && value.Length > 0 && string.IsNullOrWhiteSpace(value))
104-
{
105-
throw new ArgumentException(
106-
"Version must not be whitespace-only. Provide a non-empty version string or pass default(TaskVersion) to register an unversioned task.",
107-
nameof(version));
108-
}
109-
}
11095
}

src/Abstractions/DurableTaskVersionAttribute.cs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,9 @@ public sealed class DurableTaskVersionAttribute : Attribute
1919
/// <param name="version">The version string for the orchestrator or activity.</param>
2020
public DurableTaskVersionAttribute(string? version = null)
2121
{
22-
if (version is not null && version.Length > 0 && string.IsNullOrWhiteSpace(version))
23-
{
24-
throw new ArgumentException(
25-
"Version must not be whitespace-only. Provide a non-empty version string or omit the attribute argument to declare an unversioned task.",
26-
nameof(version));
27-
}
28-
29-
this.Version = string.IsNullOrEmpty(version) ? default : new TaskVersion(version!);
22+
// TaskVersion's constructor itself rejects whitespace-only strings and normalizes null/empty to
23+
// TaskVersion.Unversioned, so a single delegation here covers all three cases.
24+
this.Version = string.IsNullOrEmpty(version) ? TaskVersion.Unversioned : new TaskVersion(version!);
3025
}
3126

3227
/// <summary>

src/Abstractions/TaskVersion.cs

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ namespace Microsoft.DurableTask;
99
public readonly struct TaskVersion : IEquatable<TaskVersion>
1010
{
1111
/// <summary>
12-
/// A sentinel value representing an unversioned task. Equivalent to <c>default(TaskVersion)</c>.
12+
/// A sentinel value representing an unversioned task. Equivalent to <c>default(TaskVersion)</c> and
13+
/// <c>new TaskVersion(string.Empty)</c>.
1314
/// </summary>
1415
/// <remarks>
1516
/// Use this on <see cref="ActivityOptions.Version"/> to explicitly request the unversioned activity
@@ -21,21 +22,38 @@ namespace Microsoft.DurableTask;
2122
/// <summary>
2223
/// Initializes a new instance of the <see cref="TaskVersion"/> struct.
2324
/// </summary>
24-
/// <param name="version">The version of the task. Providing <c>null</c> will result in the default struct.</param>
25+
/// <param name="version">The version of the task. <c>null</c> or <see cref="string.Empty"/> produces
26+
/// an unversioned <see cref="TaskVersion"/> equal to <see cref="Unversioned"/>.</param>
27+
/// <exception cref="ArgumentException">
28+
/// Thrown when <paramref name="version"/> is non-empty but contains only whitespace. Pass <c>null</c>,
29+
/// <see cref="string.Empty"/>, or use <see cref="Unversioned"/> to represent an unversioned task.
30+
/// </exception>
2531
public TaskVersion(string version)
2632
{
27-
if (version == null)
33+
// Normalize null/empty to string.Empty so default(TaskVersion), TaskVersion.Unversioned, and
34+
// new TaskVersion("") all compare and hash identically. Without this normalization the struct's
35+
// Version field can be null, which makes Equals(null, "") return false and causes
36+
// StringComparer.OrdinalIgnoreCase.GetHashCode to throw at runtime when the struct is used as a
37+
// dictionary key.
38+
if (string.IsNullOrEmpty(version))
2839
{
29-
this.Version = null!;
40+
this.Version = string.Empty;
41+
return;
3042
}
31-
else
43+
44+
if (string.IsNullOrWhiteSpace(version))
3245
{
33-
this.Version = version;
46+
throw new ArgumentException(
47+
"Version must not be whitespace-only. Pass null, an empty string, or use TaskVersion.Unversioned to represent an unversioned task.",
48+
nameof(version));
3449
}
50+
51+
this.Version = version;
3552
}
3653

3754
/// <summary>
38-
/// Gets the version of a task.
55+
/// Gets the version of a task. Returns <see cref="string.Empty"/> for an unversioned task; never
56+
/// returns <c>null</c>.
3957
/// </summary>
4058
public string Version { get; }
4159

@@ -81,7 +99,12 @@ public TaskVersion(string version)
8199
/// <returns><c>true</c> if the two <see cref="TaskVersion"/> are equal using value semantics; otherwise <c>false</c>.</returns>
82100
public bool Equals(TaskVersion other)
83101
{
84-
return string.Equals(this.Version, other.Version, StringComparison.OrdinalIgnoreCase);
102+
// Treat null and empty Version as the same unversioned identity. Combined with normalization in
103+
// the constructor, both default(TaskVersion) and new TaskVersion("") compare equal and hash to
104+
// the same value as TaskVersion.Unversioned.
105+
string left = this.Version ?? string.Empty;
106+
string right = other.Version ?? string.Empty;
107+
return string.Equals(left, right, StringComparison.OrdinalIgnoreCase);
85108
}
86109

87110
/// <summary>
@@ -106,6 +129,9 @@ public override bool Equals(object? obj)
106129
/// <returns>A 32-bit hash code value.</returns>
107130
public override int GetHashCode()
108131
{
109-
return StringComparer.OrdinalIgnoreCase.GetHashCode(this.Version);
132+
// Null-safe: a default-constructed TaskVersion (or one created via the implicit conversion from
133+
// null) must not crash when used as a dictionary key. Treats null and empty as the same key.
134+
string value = this.Version ?? string.Empty;
135+
return StringComparer.OrdinalIgnoreCase.GetHashCode(value);
110136
}
111137
}

src/Generators/DurableTaskSourceGenerator.cs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -941,12 +941,18 @@ static void AddStandaloneGeneratedVersionHelperMethods(
941941
sourceBuilder.AppendLine(@"
942942
static StartOrchestrationOptions? ApplyGeneratedVersion(StartOrchestrationOptions? options, string version)
943943
{
944-
if (options?.Version is { Version: { Length: > 0 } existingVersion })
945-
{
946-
if (!string.Equals(existingVersion, version, System.StringComparison.OrdinalIgnoreCase))
944+
if (options?.Version is TaskVersion existingStart)
945+
{
946+
// Any non-null Version on the options is an explicit caller selection — including
947+
// TaskVersion.Unversioned and the empty-string equivalent. The generated helper bakes a
948+
// specific version into its method name, so a disagreement is always a contradiction;
949+
// the silently-overridden case is precisely what we are trying to prevent.
950+
string existingValue = existingStart.Version ?? string.Empty;
951+
if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase))
947952
{
953+
string requested = string.IsNullOrEmpty(existingValue) ? ""<unversioned>"" : ""'"" + existingValue + ""'"";
948954
throw new System.InvalidOperationException(
949-
$""The generated helper targets version '{version}' but options.Version was set to '{existingVersion}'. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version."");
955+
$""The generated helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified ScheduleNewOrchestrationInstanceAsync overload to schedule a different version."");
950956
}
951957
952958
return options;
@@ -968,12 +974,14 @@ static void AddStandaloneGeneratedVersionHelperMethods(
968974
969975
static TaskOptions? ApplyGeneratedVersion(TaskOptions? options, string version)
970976
{
971-
if (options is SubOrchestrationOptions { Version: { Version: { Length: > 0 } existingSubVersion } })
977+
if (options is SubOrchestrationOptions { Version: TaskVersion existingSub })
972978
{
973-
if (!string.Equals(existingSubVersion, version, System.StringComparison.OrdinalIgnoreCase))
979+
string existingValue = existingSub.Version ?? string.Empty;
980+
if (!string.Equals(existingValue, version, System.StringComparison.OrdinalIgnoreCase))
974981
{
982+
string requested = string.IsNullOrEmpty(existingValue) ? ""<unversioned>"" : ""'"" + existingValue + ""'"";
975983
throw new System.InvalidOperationException(
976-
$""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to '{existingSubVersion}'. Use the unqualified CallSubOrchestratorAsync overload to call a different version."");
984+
$""The generated sub-orchestrator helper targets version '{version}' but options.Version was set to {requested}. Use the unqualified CallSubOrchestratorAsync overload to call a different version."");
977985
}
978986
979987
return options;
@@ -1008,13 +1016,17 @@ static void AddStandaloneGeneratedVersionHelperMethods(
10081016
static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version)
10091017
{
10101018
if (options is ActivityOptions activityOptions
1011-
&& activityOptions.Version is TaskVersion explicitVersion
1012-
&& !string.IsNullOrWhiteSpace(explicitVersion.Version))
1019+
&& activityOptions.Version is TaskVersion explicitVersion)
10131020
{
1014-
if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase))
1021+
// Any non-null ActivityOptions.Version is an explicit caller selection — including
1022+
// TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper-
1023+
// baked version is always a contradiction, so we throw rather than silently override.
1024+
string explicitValue = explicitVersion.Version ?? string.Empty;
1025+
if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase))
10151026
{
1027+
string requested = string.IsNullOrEmpty(explicitValue) ? ""<unversioned>"" : ""'"" + explicitValue + ""'"";
10161028
throw new System.InvalidOperationException(
1017-
$""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version."");
1029+
$""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version."");
10181030
}
10191031
10201032
return options;

src/Worker/Core/Worker.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ The worker is responsible for processing durable task work items.</PackageDescri
2121

2222
<ItemGroup>
2323
<InternalsVisibleTo Include="Microsoft.DurableTask.Worker.Grpc" Key="$(StrongNamePublicKey)" />
24+
<InternalsVisibleTo Include="Microsoft.DurableTask.Grpc.IntegrationTests" Key="$(StrongNamePublicKey)" />
2425
</ItemGroup>
2526

2627
<ItemGroup>

test/Generators.Tests/VersionedActivityTests.cs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,17 @@ public static Task<string> CallInvoiceActivityAsync(this TaskOrchestrationContex
4444
static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version)
4545
{
4646
if (options is ActivityOptions activityOptions
47-
&& activityOptions.Version is TaskVersion explicitVersion
48-
&& !string.IsNullOrWhiteSpace(explicitVersion.Version))
47+
&& activityOptions.Version is TaskVersion explicitVersion)
4948
{
50-
if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase))
49+
// Any non-null ActivityOptions.Version is an explicit caller selection — including
50+
// TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper-
51+
// baked version is always a contradiction, so we throw rather than silently override.
52+
string explicitValue = explicitVersion.Version ?? string.Empty;
53+
if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase))
5154
{
55+
string requested = string.IsNullOrEmpty(explicitValue) ? ""<unversioned>"" : ""'"" + explicitValue + ""'"";
5256
throw new System.InvalidOperationException(
53-
$""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version."");
57+
$""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version."");
5458
}
5559
5660
return options;
@@ -136,13 +140,17 @@ public static Task<string> CallInvoiceActivity_v2Async(this TaskOrchestrationCon
136140
static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version)
137141
{
138142
if (options is ActivityOptions activityOptions
139-
&& activityOptions.Version is TaskVersion explicitVersion
140-
&& !string.IsNullOrWhiteSpace(explicitVersion.Version))
143+
&& activityOptions.Version is TaskVersion explicitVersion)
141144
{
142-
if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase))
145+
// Any non-null ActivityOptions.Version is an explicit caller selection — including
146+
// TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper-
147+
// baked version is always a contradiction, so we throw rather than silently override.
148+
string explicitValue = explicitVersion.Version ?? string.Empty;
149+
if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase))
143150
{
151+
string requested = string.IsNullOrEmpty(explicitValue) ? ""<unversioned>"" : ""'"" + explicitValue + ""'"";
144152
throw new System.InvalidOperationException(
145-
$""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version."");
153+
$""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version."");
146154
}
147155
148156
return options;
@@ -220,13 +228,17 @@ public static Task<string> CallInvoiceActivityAsync(this TaskOrchestrationContex
220228
static TaskOptions? ApplyGeneratedActivityVersion(TaskOptions? options, string version)
221229
{
222230
if (options is ActivityOptions activityOptions
223-
&& activityOptions.Version is TaskVersion explicitVersion
224-
&& !string.IsNullOrWhiteSpace(explicitVersion.Version))
231+
&& activityOptions.Version is TaskVersion explicitVersion)
225232
{
226-
if (!string.Equals(explicitVersion.Version, version, System.StringComparison.OrdinalIgnoreCase))
233+
// Any non-null ActivityOptions.Version is an explicit caller selection — including
234+
// TaskVersion.Unversioned and the empty-string equivalent. Disagreement with the helper-
235+
// baked version is always a contradiction, so we throw rather than silently override.
236+
string explicitValue = explicitVersion.Version ?? string.Empty;
237+
if (!string.Equals(explicitValue, version, System.StringComparison.OrdinalIgnoreCase))
227238
{
239+
string requested = string.IsNullOrEmpty(explicitValue) ? ""<unversioned>"" : ""'"" + explicitValue + ""'"";
228240
throw new System.InvalidOperationException(
229-
$""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to '{explicitVersion.Version}'. Use the unqualified CallActivityAsync overload to call a different version."");
241+
$""The generated activity helper targets version '{version}' but ActivityOptions.Version was set to {requested}. Use the unqualified CallActivityAsync overload to call a different version."");
230242
}
231243
232244
return options;

0 commit comments

Comments
 (0)