diff --git a/CHANGELOG.md b/CHANGELOG.md index afc52e960..ffc961a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Add user agent header to gRPC called in ([#417](https://github.com/microsoft/durabletask-dotnet/pull/417)) - Enrich User-Agent Header in gRPC Metadata to indicate Client or Worker as caller ([#421](https://github.com/microsoft/durabletask-dotnet/pull/421)) - Add extension methods for registering entities by type ([#427](https://github.com/microsoft/durabletask-dotnet/pull/427)) +- Add TaskVersion and utilize it for version overrides when starting orchestrations ([#416](https://github.com/microsoft/durabletask-dotnet/pull/416)) ## v1.10.0 diff --git a/src/Abstractions/TaskName.cs b/src/Abstractions/TaskName.cs index 62d36f562..2ee9dcdf4 100644 --- a/src/Abstractions/TaskName.cs +++ b/src/Abstractions/TaskName.cs @@ -16,6 +16,7 @@ namespace Microsoft.DurableTask; /// The name of the task. Providing null will yield the default struct. public TaskName(string name) { +#pragma warning disable 0618 if (name is null) { // Force the default struct when null is passed in. @@ -27,6 +28,7 @@ public TaskName(string name) this.Name = name; this.Version = string.Empty; // expose setting Version only when we actually consume it. } +#pragma warning restore 0618 } /// @@ -44,6 +46,7 @@ public TaskName(string name) /// Task versions is currently locked to as it is not yet integrated into task /// identification. This is being left here as we intend to support it soon. /// + [Obsolete("Refer to TaskVersion instead.")] public string Version { get; } /// @@ -122,6 +125,7 @@ public override int GetHashCode() /// The name and optional version of the current instance. public override string ToString() { +#pragma warning disable 0618 if (string.IsNullOrEmpty(this.Version)) { return this.Name; @@ -130,5 +134,6 @@ public override string ToString() { return this.Name + ":" + this.Version; } +#pragma warning restore 0618 } } diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index d3e06e5ca..6afd5f708 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -90,6 +90,11 @@ public SubOrchestrationOptions(TaskOptions options, string? instanceId = null) /// Gets the orchestration instance ID. /// public string? InstanceId { get; init; } + + /// + /// Gets the version to associate with the sub-orchestration instance. + /// + public TaskVersion Version { get; init; } = default!; } /// @@ -108,4 +113,9 @@ public record StartOrchestrationOptions(string? InstanceId = null, DateTimeOffse /// Gets the tags to associate with the orchestration instance. /// public IReadOnlyDictionary Tags { get; init; } = ImmutableDictionary.Create(); + + /// + /// Gets the version to associate with the orchestration instance. + /// + public TaskVersion? Version { get; init; } } diff --git a/src/Abstractions/TaskVersion.cs b/src/Abstractions/TaskVersion.cs new file mode 100644 index 000000000..5d6d1bfec --- /dev/null +++ b/src/Abstractions/TaskVersion.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// The version of a durable task. +/// +public readonly struct TaskVersion : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The version of the task. Providing null will result in the default struct. + public TaskVersion(string version) + { + if (version == null) + { + this.Version = null!; + } + else + { + this.Version = version; + } + } + + /// + /// Gets the version of a task. + /// + public string Version { get; } + + /// + /// Implicitly converts a into a of the property value. + /// + /// The to be converted into a . + public static implicit operator string(TaskVersion value) => value.Version; + + /// + /// Implicitly converts a into a . + /// + /// The to convert into a . + public static implicit operator TaskVersion(string value) => new TaskVersion(value); + + /// + /// Compares two structs for equality. + /// + /// The first to compare. + /// The second to compare. + /// true if the two objects are equal; otherwise false. + public static bool operator ==(TaskVersion a, TaskVersion b) + { + return a.Equals(b); + } + + /// + /// Compares two structs for inequality. + /// + /// The first to compare. + /// The second to compare. + /// false if the two objects are equal; otherwise true. + public static bool operator !=(TaskVersion a, TaskVersion b) + { + return !a.Equals(b); + } + + /// + /// Gets a value indicating whether to objects + /// are equal using value semantics. + /// + /// The other to compare to. + /// true if the two are equal using value semantics; otherwise false. + public bool Equals(TaskVersion other) + { + return string.Equals(this.Version, other.Version, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets a value indicating whether to objects + /// are equal using value semantics. + /// + /// The other object to compare to. + /// true if the two objects are equal using value semantics; otherwise false. + public override bool Equals(object? obj) + { + if (obj is not TaskVersion other) + { + return false; + } + + return this.Equals(other); + } + + /// + /// Calculates a hash code value for the current instance. + /// + /// A 32-bit hash code value. + public override int GetHashCode() + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(this.Version); + } +} diff --git a/src/Client/Grpc/GrpcDurableTaskClient.cs b/src/Client/Grpc/GrpcDurableTaskClient.cs index a4960ae93..6246435be 100644 --- a/src/Client/Grpc/GrpcDurableTaskClient.cs +++ b/src/Client/Grpc/GrpcDurableTaskClient.cs @@ -78,10 +78,11 @@ public override async Task ScheduleNewOrchestrationInstanceAsync( { Check.NotEntity(this.options.EnableEntitySupport, options?.InstanceId); + // We're explicitly OK with an empty version from the options as that had to be explicitly set. It should take precedence over the default. string version = string.Empty; - if (!string.IsNullOrEmpty(orchestratorName.Version)) + if (options?.Version is { } v) { - version = orchestratorName.Version; + version = v; } else if (!string.IsNullOrEmpty(this.options.DefaultVersion)) { diff --git a/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs b/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs index fe7625a88..bcf8eb98a 100644 --- a/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs +++ b/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs @@ -172,7 +172,7 @@ public override async Task ScheduleNewOrchestrationInstanceAsync( Event = new ExecutionStartedEvent(-1, serializedInput) { Name = orchestratorName.Name, - Version = orchestratorName.Version, + Version = options?.Version ?? string.Empty, OrchestrationInstance = instance, ScheduledStartTime = options?.StartAt?.UtcDateTime, Tags = options?.Tags != null ? options.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) : null, diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs index 322e7d403..ee065f528 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs @@ -102,6 +102,7 @@ public static IDurableTaskWorkerBuilder UseVersioning(this IDurableTaskWorkerBui options.Versioning = new VersioningOptions { Version = versionOptions.Version, + DefaultVersion = versionOptions.DefaultVersion, MatchStrategy = versionOptions.MatchStrategy, FailureStrategy = versionOptions.FailureStrategy, }; diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index 395666c9a..b2bdca04b 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -203,6 +203,11 @@ public class VersioningOptions /// public string Version { get; set; } = string.Empty; + /// + /// Gets or sets the default version that will be used for starting new orchestrations. + /// + public string DefaultVersion { get; set; } = string.Empty; + /// /// Gets or sets the versioning strategy for the Durable Task worker. /// diff --git a/src/Worker/Core/Shims/TaskEntityShim.cs b/src/Worker/Core/Shims/TaskEntityShim.cs index 800b42bb6..8f47a88b2 100644 --- a/src/Worker/Core/Shims/TaskEntityShim.cs +++ b/src/Worker/Core/Shims/TaskEntityShim.cs @@ -221,7 +221,7 @@ public override string ScheduleNewOrchestration(TaskName name, object? input = n this.operationActions.Add(new StartNewOrchestrationOperationAction() { Name = name.Name, - Version = name.Version, + Version = options?.Version ?? string.Empty, InstanceId = instanceId, Input = this.dataConverter.Serialize(input), ScheduledStartTime = options?.StartAt?.UtcDateTime, diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index a964020de..205ebb763 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -139,6 +139,7 @@ public override async Task CallActivityAsync( try { // TODO: Cancellation (https://github.com/microsoft/durabletask-dotnet/issues/7) +#pragma warning disable 0618 if (options?.Retry?.Policy is RetryPolicy policy) { return await this.innerContext.ScheduleWithRetry( @@ -165,6 +166,7 @@ public override async Task CallActivityAsync( // Hide the core DTFx types and instead use our own throw new TaskFailedException(name, e.ScheduleId, e); } +#pragma warning restore 0618 } /// @@ -177,10 +179,12 @@ public override async Task CallSubOrchestratorAsync( static string? GetInstanceId(TaskOptions? options) => options is SubOrchestrationOptions derived ? derived.InstanceId : null; string instanceId = GetInstanceId(options) ?? this.NewGuid().ToString("N"); + string defaultVersion = this.invocationContext.Options?.Versioning?.DefaultVersion ?? string.Empty; + string version = options is SubOrchestrationOptions subOptions ? subOptions.Version : defaultVersion; Check.NotEntity(this.invocationContext.Options.EnableEntitySupport, instanceId); - // if this orchestration uses entities, first validate that the suborchsestration call is allowed in the current context + // if this orchestration uses entities, first validate that the suborchestration call is allowed in the current context if (this.entityFeature != null && !this.entityFeature.EntityContext.ValidateSuborchestrationTransition(out string? errorMsg)) { throw new InvalidOperationException(errorMsg); @@ -192,7 +196,7 @@ public override async Task CallSubOrchestratorAsync( { return await this.innerContext.CreateSubOrchestrationInstanceWithRetry( orchestratorName.Name, - orchestratorName.Version, + version, instanceId, policy.ToDurableTaskCoreRetryOptions(), input); @@ -202,7 +206,7 @@ public override async Task CallSubOrchestratorAsync( return await this.InvokeWithCustomRetryHandler( () => this.innerContext.CreateSubOrchestrationInstance( orchestratorName.Name, - orchestratorName.Version, + version, instanceId, input), orchestratorName.Name, @@ -213,7 +217,7 @@ public override async Task CallSubOrchestratorAsync( { return await this.innerContext.CreateSubOrchestrationInstance( orchestratorName.Name, - orchestratorName.Version, + version, instanceId, input); } diff --git a/test/Abstractions.Tests/TaskNameTests.cs b/test/Abstractions.Tests/TaskNameTests.cs index b5cfc4350..fff8530d6 100644 --- a/test/Abstractions.Tests/TaskNameTests.cs +++ b/test/Abstractions.Tests/TaskNameTests.cs @@ -17,7 +17,9 @@ public void Ctor_EmptyName_Okay() { TaskName name = new(string.Empty); name.Name.Should().Be(string.Empty); +#pragma warning disable 0618 name.Version.Should().Be(string.Empty); +#pragma warning restore 0618 } [Theory] diff --git a/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs b/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs index a9a5d7caa..5eba17ede 100644 --- a/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs +++ b/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs @@ -400,7 +400,7 @@ static TaskMessage MatchStartExecutionMessage(TaskName name, object? input, Star } return Guid.TryParse(m.OrchestrationInstance.ExecutionId, out _) - && @event.Name == name.Name && @event.Version == name.Version + && @event.Name == name.Name && @event.Version == (options?.Version ?? string.Empty) && @event.OrchestrationInstance == m.OrchestrationInstance && @event.EventId == -1 && @event.Input == JsonDataConverter.Default.Serialize(input); diff --git a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs index 634427bc6..b64a5fa87 100644 --- a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs +++ b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.DurableTask.Abstractions; using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Tests.Logging; using Microsoft.DurableTask.Worker; @@ -213,7 +214,7 @@ public async Task SingleActivity() IReadOnlyCollection workerLogs = this.GetLogs("Microsoft.DurableTask.Worker"); Assert.NotEmpty(workerLogs); - + // Validate logs. Assert.Single(workerLogs, log => MatchLog( log, @@ -652,7 +653,6 @@ public async Task OrchestrationVersionPassedThroughContext() Assert.NotNull(output); Assert.Equal(output, $"Orchestration version: {version}"); - } [Fact] @@ -834,6 +834,116 @@ public async Task OrchestrationVersioning_MatchTypeCurrentOrOlder_VersionSuccess Assert.Equal(output, $"Orchestration version: {clientVersion}"); } + [Fact] + public async Task SubOrchestrationInheritsDefaultVersion() + { + var version = "0.1"; + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => tasks + .AddOrchestratorFunc("Versioned_Orchestration", (ctx, input) => + { + return ctx.CallSubOrchestratorAsync("Versioned_Sub_Orchestration"); + }) + .AddOrchestratorFunc("Versioned_Sub_Orchestration", (ctx, input) => + { + return ctx.CallActivityAsync("Versioned_Activity", ctx.Version); + }) + .AddActivityFunc("Versioned_Activity", (ctx, input) => + { + return $"Sub Orchestration version: {input}"; + })); + b.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions + { + DefaultVersion = version + }); + }, c => + { + c.UseDefaultVersion(version); + }); + + var instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync("Versioned_Orchestration", input: string.Empty); + var result = await server.Client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true, this.TimeoutToken); + var output = result.ReadOutputAs(); + + Assert.NotNull(output); + Assert.Equal($"Sub Orchestration version: {version}", output); + } + + [Theory] + [InlineData("0.2")] + [InlineData("")] + public async Task OrchestrationTaskVersionOverridesDefaultVersion(string overrideVersion) + { + var version = "0.1"; + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => tasks + .AddOrchestratorFunc("Versioned_Orchestration", (ctx, input) => + { + return ctx.CallActivityAsync("Versioned_Activity", ctx.Version); + }) + .AddActivityFunc("Versioned_Activity", (ctx, input) => + { + return $"Orchestration version: {input}"; + })); + }, c => + { + c.UseDefaultVersion(version); + }); + + var instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync("Versioned_Orchestration", string.Empty, new StartOrchestrationOptions + { + Version = overrideVersion + }); + var result = await server.Client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true, this.TimeoutToken); + var output = result.ReadOutputAs(); + + Assert.NotNull(output); + Assert.Equal($"Orchestration version: {overrideVersion}", output); + } + + [Theory] + [InlineData("0.2")] + [InlineData("")] + public async Task SubOrchestrationTaskVersionOverridesDefaultVersion(string overrideVersion) + { + var version = "0.1"; + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => tasks + .AddOrchestratorFunc("Versioned_Orchestration", (ctx, input) => + { + return ctx.CallSubOrchestratorAsync("Versioned_Sub_Orchestration", new SubOrchestrationOptions + { + Version = overrideVersion + }); + }) + .AddOrchestratorFunc("Versioned_Sub_Orchestration", (ctx, input) => + { + return ctx.CallActivityAsync("Versioned_Activity", ctx.Version); + }) + .AddActivityFunc("Versioned_Activity", (ctx, input) => + { + return $"Sub Orchestration version: {input}"; + })); + b.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions + { + DefaultVersion = version, + }); + }, c => + { + c.UseDefaultVersion(version); + }); + + var instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync("Versioned_Orchestration", input: string.Empty); + var result = await server.Client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true, this.TimeoutToken); + var output = result.ReadOutputAs(); + + Assert.NotNull(output); + Assert.Equal($"Sub Orchestration version: {overrideVersion}", output); + } + // TODO: Test for multiple external events with the same name // TODO: Test for ContinueAsNew with external events that carry over // TODO: Test for catching activity exceptions of specific types