diff --git a/Test/DurableTask.Core.Tests/TaskOrchestrationDispatcherVersioningTests.cs b/Test/DurableTask.Core.Tests/TaskOrchestrationDispatcherVersioningTests.cs new file mode 100644 index 000000000..c56ec6418 --- /dev/null +++ b/Test/DurableTask.Core.Tests/TaskOrchestrationDispatcherVersioningTests.cs @@ -0,0 +1,373 @@ +// ---------------------------------------------------------------------------------- +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace DurableTask.Core.Tests +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using DurableTask.Core.Exceptions; + using DurableTask.Core.History; + using DurableTask.Core.Logging; + using DurableTask.Core.Middleware; + using DurableTask.Core.Settings; + using DurableTask.Emulator; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using static DurableTask.Core.Settings.VersioningSettings; + + [TestClass] + public class TaskOrchestrationDispatcherVersioningTests + { + const string InternalName = "Internal.Orchestration"; + + [TestMethod] + [DataRow(VersionMatchStrategy.Strict, null, false)] + [DataRow(VersionMatchStrategy.Strict, "", false)] + [DataRow(VersionMatchStrategy.Strict, "1.0", true)] + [DataRow(VersionMatchStrategy.Strict, "1.0.0", false)] + [DataRow(VersionMatchStrategy.Strict, "2.0", false)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, null, true)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "", true)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "0.9", true)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "1.0", true)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "2.0", false)] + [DataRow(VersionMatchStrategy.None, "", true)] + [DataRow(VersionMatchStrategy.None, "2.0", true)] + public async Task BusinessOrchestrationsRetainVersionPolicies( + VersionMatchStrategy matchStrategy, string version, bool shouldExecute) + { + foreach (VersionFailureStrategy failureStrategy in Enum.GetValues(typeof(VersionFailureStrategy))) + { + foreach (bool configureExclusion in new[] { false, true }) + { + var settings = new VersioningSettings + { + Version = "1.0", + MatchStrategy = matchStrategy, + FailureStrategy = failureStrategy, + }; + if (configureExclusion) + { + settings.ExcludedOrchestrationNames.Add(InternalName); + } + + await AssertVersionPolicyAsync(settings, "Business.Orchestration", version, shouldExecute); + } + } + } + + [TestMethod] + [DataRow(InternalName, true)] + [DataRow("internal.orchestration", false)] + [DataRow("Internal.Orchestration.Child", false)] + [DataRow("Internal", false)] + [DataRow("Business.Orchestration", false)] + public async Task OnlyExactExcludedUnversionedNamesExecute(string name, bool shouldExecute) + { + foreach (VersionFailureStrategy failureStrategy in Enum.GetValues(typeof(VersionFailureStrategy))) + { + foreach (string version in new[] { null, string.Empty }) + { + var settings = CreateSettings(failureStrategy); + await AssertVersionPolicyAsync(settings, name, version, shouldExecute); + } + } + } + + [TestMethod] + [DataRow(VersionMatchStrategy.Strict, "1.0", true)] + [DataRow(VersionMatchStrategy.Strict, "2.0", false)] + [DataRow(VersionMatchStrategy.Strict, " ", false)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "0.9", true)] + [DataRow(VersionMatchStrategy.CurrentOrOlder, "2.0", false)] + public async Task ExcludedNamesWithNonemptyVersionsRetainVersionPolicies( + VersionMatchStrategy matchStrategy, string version, bool shouldExecute) + { + foreach (VersionFailureStrategy failureStrategy in Enum.GetValues(typeof(VersionFailureStrategy))) + { + var settings = CreateSettings(failureStrategy); + settings.MatchStrategy = matchStrategy; + await AssertVersionPolicyAsync(settings, InternalName, version, shouldExecute); + } + } + + [TestMethod] + public async Task NullAndDefaultSettingsKeepVersioningDisabled() + { + foreach (VersioningSettings settings in new[] { null, new VersioningSettings() }) + { + await AssertVersionPolicyAsync(settings, InternalName, string.Empty, shouldExecute: true); + await AssertVersionPolicyAsync(settings, "Business.Orchestration", "2.0", shouldExecute: true); + } + } + + [TestMethod] + public async Task ExclusionDoesNotBypassOrchestrationResolution() + { + using var service = new RecordingOrchestrationService(); + var dispatcher = new TestDispatcher( + service, new NameVersionObjectManager(), + new DispatchMiddlewarePipeline(), CreateSettings(VersionFailureStrategy.Fail)); + TaskOrchestrationWorkItem workItem = CreateWorkItem(InternalName, string.Empty); + + await Assert.ThrowsExceptionAsync(() => dispatcher.ProcessAsync(workItem)); + + Assert.AreEqual(0, service.AbandonCount); + Assert.IsNull(service.CompletedState); + Assert.AreEqual(string.Empty, workItem.OrchestrationRuntimeState.Version); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + public async Task ExclusionAppliesToReplayAndContinueAsNewWithoutChangingVersion(string version) + { + using var service = new RecordingOrchestrationService(); + var orchestration = new TimerOrchestration(); + var manager = new NameVersionObjectManager(); + manager.Add(new NameValueObjectCreator(InternalName, version, orchestration)); + var pipeline = new DispatchMiddlewarePipeline(); + int middlewareCalls = 0; + pipeline.Add((context, next) => + { + OrchestrationRuntimeState state = context.GetProperty(); + Assert.AreEqual(InternalName, state.Name); + Assert.AreEqual(version, state.Version); + middlewareCalls++; + return next(); + }); + var dispatcher = new TestDispatcher(service, manager, pipeline, CreateSettings(VersionFailureStrategy.Fail)); + TaskOrchestrationWorkItem workItem = CreateWorkItem(InternalName, version); + + await dispatcher.ProcessAsync(workItem); + for (int generation = 0; generation < 3; generation++) + { + Assert.AreEqual(OrchestrationStatus.Running, service.CompletedState.OrchestrationStatus); + string executionId = service.CompletedState.OrchestrationInstance.ExecutionId; + TaskMessage timerMessage = service.TimerMessages.Single(); + Assert.IsInstanceOfType(timerMessage.Event, typeof(TimerFiredEvent)); + workItem = new TaskOrchestrationWorkItem + { + InstanceId = workItem.InstanceId, + LockedUntilUtc = DateTime.MaxValue, + OrchestrationRuntimeState = new OrchestrationRuntimeState(service.CompletedRuntimeState.Events), + NewMessages = new[] { timerMessage }, + }; + + await dispatcher.ProcessAsync(workItem); + + Assert.AreEqual(0, service.AbandonCount); + Assert.AreEqual(version, service.CompletedState.Version); + if (generation < 2) + { + Assert.AreNotEqual(executionId, service.CompletedState.OrchestrationInstance.ExecutionId); + } + } + + Assert.AreEqual(OrchestrationStatus.Completed, service.CompletedState.OrchestrationStatus); + Assert.AreEqual("2", service.CompletedState.Output); + Assert.AreEqual(6, orchestration.ExecutionCount); + Assert.AreEqual(6, middlewareCalls); + } + + [TestMethod] + [DataRow(VersionFailureStrategy.Reject)] + [DataRow(VersionFailureStrategy.Fail)] + public async Task ContinueAsNewToNonemptyVersionReappliesVersionPolicy(VersionFailureStrategy failureStrategy) + { + using var service = new RecordingOrchestrationService(); + var orchestration = new VersionChangingOrchestration(); + var manager = new NameVersionObjectManager(); + manager.Add(new NameValueObjectCreator(InternalName, string.Empty, orchestration)); + manager.Add(new NameValueObjectCreator(InternalName, "2.0", orchestration)); + var dispatcher = new TestDispatcher(service, manager, new DispatchMiddlewarePipeline(), CreateSettings(failureStrategy)); + TaskOrchestrationWorkItem workItem = CreateWorkItem(InternalName, string.Empty); + + await dispatcher.ProcessAsync(workItem); + + Assert.AreEqual(1, orchestration.ExecutionCount); + Assert.AreEqual("2.0", workItem.OrchestrationRuntimeState.Version); + AssertVersionFailure(service, failureStrategy); + } + + static VersioningSettings CreateSettings(VersionFailureStrategy failureStrategy) + { + var settings = new VersioningSettings + { + Version = "1.0", + MatchStrategy = VersionMatchStrategy.Strict, + FailureStrategy = failureStrategy, + }; + settings.ExcludedOrchestrationNames.Add(InternalName); + return settings; + } + + static async Task AssertVersionPolicyAsync(VersioningSettings settings, string name, string version, bool shouldExecute) + { + using var service = new RecordingOrchestrationService(); + var orchestration = new CompletingOrchestration(); + var manager = new NameVersionObjectManager(); + manager.Add(new NameValueObjectCreator(name, version, orchestration)); + var pipeline = new DispatchMiddlewarePipeline(); + int middlewareCalls = 0; + pipeline.Add((context, next) => + { + Assert.AreSame(orchestration, context.GetProperty()); + Assert.AreEqual(version, context.GetProperty().Version); + middlewareCalls++; + return next(); + }); + var dispatcher = new TestDispatcher(service, manager, pipeline, settings); + TaskOrchestrationWorkItem workItem = CreateWorkItem(name, version); + + await dispatcher.ProcessAsync(workItem); + + Assert.AreEqual(shouldExecute ? 1 : 0, orchestration.ExecutionCount); + Assert.AreEqual(shouldExecute ? 1 : 0, middlewareCalls); + Assert.AreEqual(version, workItem.OrchestrationRuntimeState.Version); + if (shouldExecute) + { + Assert.AreEqual(0, service.AbandonCount); + Assert.AreEqual(OrchestrationStatus.Completed, service.CompletedState.OrchestrationStatus); + Assert.AreEqual("0", service.CompletedState.Output); + } + else + { + AssertVersionFailure(service, settings.FailureStrategy); + } + } + + static void AssertVersionFailure(RecordingOrchestrationService service, VersionFailureStrategy failureStrategy) + { + if (failureStrategy == VersionFailureStrategy.Reject) + { + Assert.AreEqual(1, service.AbandonCount); + Assert.IsNull(service.CompletedState); + } + else + { + Assert.AreEqual(0, service.AbandonCount); + Assert.AreEqual(OrchestrationStatus.Failed, service.CompletedState.OrchestrationStatus); + Assert.AreEqual("VersionMismatch", service.CompletedState.FailureDetails.ErrorType); + } + } + + static TaskOrchestrationWorkItem CreateWorkItem(string name, string version) + { + var instance = new OrchestrationInstance + { + InstanceId = Guid.NewGuid().ToString(), + ExecutionId = Guid.NewGuid().ToString(), + }; + return new TaskOrchestrationWorkItem + { + InstanceId = instance.InstanceId, + LockedUntilUtc = DateTime.MaxValue, + OrchestrationRuntimeState = new OrchestrationRuntimeState(), + NewMessages = new[] + { + new TaskMessage + { + OrchestrationInstance = instance, + Event = new ExecutionStartedEvent(-1, "0") + { + Name = name, + Version = version, + OrchestrationInstance = instance, + }, + }, + }, + }; + } + + sealed class TestDispatcher : TaskOrchestrationDispatcher + { + public TestDispatcher( + IOrchestrationService service, + INameVersionObjectManager manager, + DispatchMiddlewarePipeline pipeline, + VersioningSettings settings) + : base(service, manager, pipeline, new LogHelper(null), ErrorPropagationMode.UseFailureDetails, settings, null) + { + } + + public Task ProcessAsync(TaskOrchestrationWorkItem workItem) => this.OnProcessWorkItemAsync(workItem); + } + + sealed class RecordingOrchestrationService : LocalOrchestrationService, IOrchestrationService + { + public int AbandonCount { get; private set; } + public OrchestrationState CompletedState { get; private set; } + public OrchestrationRuntimeState CompletedRuntimeState { get; private set; } + public IList TimerMessages { get; private set; } + + public new Task AbandonTaskOrchestrationWorkItemAsync(TaskOrchestrationWorkItem workItem) + { + this.AbandonCount++; + return Task.CompletedTask; + } + + public new Task CompleteTaskOrchestrationWorkItemAsync( + TaskOrchestrationWorkItem workItem, OrchestrationRuntimeState newOrchestrationRuntimeState, + IList outboundMessages, IList orchestratorMessages, + IList timerMessages, TaskMessage continuedAsNewMessage, OrchestrationState state) + { + this.CompletedState = state; + this.CompletedRuntimeState = newOrchestrationRuntimeState; + this.TimerMessages = timerMessages; + return Task.CompletedTask; + } + } + + sealed class CompletingOrchestration : TaskOrchestration + { + public int ExecutionCount { get; private set; } + + public override Task RunTask(OrchestrationContext context, int input) + { + this.ExecutionCount++; + return Task.FromResult(input); + } + } + + sealed class TimerOrchestration : TaskOrchestration + { + public int ExecutionCount { get; private set; } + + public override async Task RunTask(OrchestrationContext context, int input) + { + this.ExecutionCount++; + await context.CreateTimer(context.CurrentUtcDateTime.AddMinutes(1), input); + if (input < 2) + { + context.ContinueAsNew(input + 1); + } + + return input; + } + } + + sealed class VersionChangingOrchestration : TaskOrchestration + { + public int ExecutionCount { get; private set; } + + public override Task RunTask(OrchestrationContext context, int input) + { + this.ExecutionCount++; + context.ContinueAsNew("2.0", input); + return Task.FromResult(input); + } + } + } +} diff --git a/Test/DurableTask.Core.Tests/VersionSettingsTests.cs b/Test/DurableTask.Core.Tests/VersionSettingsTests.cs index 44128164d..f3b47b765 100644 --- a/Test/DurableTask.Core.Tests/VersionSettingsTests.cs +++ b/Test/DurableTask.Core.Tests/VersionSettingsTests.cs @@ -19,6 +19,33 @@ namespace DurableTask.Core.Tests [TestClass] public class VersionSettingsTests { + [TestMethod] + public void ExcludedOrchestrationNamesAreEmptyAndInstanceScopedByDefault() + { + var settings = new VersioningSettings(); + var otherSettings = new VersioningSettings(); + + Assert.AreEqual(0, settings.ExcludedOrchestrationNames.Count); + settings.ExcludedOrchestrationNames.Add("Internal.Orchestration"); + + Assert.AreEqual(0, otherSettings.ExcludedOrchestrationNames.Count); + Assert.AreEqual(string.Empty, settings.Version); + Assert.AreEqual(VersioningSettings.VersionMatchStrategy.None, settings.MatchStrategy); + Assert.AreEqual(VersioningSettings.VersionFailureStrategy.Reject, settings.FailureStrategy); + } + + [TestMethod] + public void ExcludedOrchestrationNamesUseExactOrdinalMatching() + { + var settings = new VersioningSettings(); + Assert.IsTrue(settings.ExcludedOrchestrationNames.Add("Internal.Orchestration")); + Assert.IsFalse(settings.ExcludedOrchestrationNames.Add("Internal.Orchestration")); + + Assert.IsTrue(settings.ExcludedOrchestrationNames.Contains("Internal.Orchestration")); + Assert.IsFalse(settings.ExcludedOrchestrationNames.Contains("internal.orchestration")); + Assert.IsFalse(settings.ExcludedOrchestrationNames.Contains("Internal.Orchestration.Child")); + } + [TestMethod] [DataRow("1.0.0", "1.0.0", 0)] [DataRow("1.1.0", "1.0.0", 1)] diff --git a/docs/features/versioning.md b/docs/features/versioning.md index 0eb6d21c4..84eb7e801 100644 --- a/docs/features/versioning.md +++ b/docs/features/versioning.md @@ -224,6 +224,22 @@ var instance = await client.CreateOrchestrationInstanceAsync( | `Reject` | Default. Abandon the work item so another worker can pick it up (or retry later). | | `Fail` | Fail the orchestration with a `VersionMismatch` error. | +#### Excluding Unversioned Infrastructure Orchestrations + +A host that registers infrastructure orchestrations alongside business orchestrations can exclude specific unversioned infrastructure orchestration names from worker version checks: + +```csharp +// Use the exact name of an explicitly registered unversioned infrastructure orchestration. +// Configure exclusions before starting the worker. +versioningSettings.ExcludedOrchestrationNames.Add(registeredInfrastructureOrchestrationName); +``` + +`ExcludedOrchestrationNames` is empty by default and uses exact, case-sensitive ordinal matching, not prefixes or patterns. An exclusion applies only when the execution version is `null` or the empty string. Nonempty versions, including whitespace, still follow the configured version matching and failure strategies even when the name is in the set. Business orchestration names not in the set retain the same version checks. + +The exclusion skips only the worker version check, which runs before orchestration middleware and execution. It does not register an orchestration, change its execution version or history, or bypass name/version lookup. Register and start these infrastructure orchestrations with their explicit unversioned identity. The same check applies on replay and after `ContinueAsNew`; continuing with a nonempty version re-enables the normal version policy. + +Do not modify the set while the worker is running. + #### Blue-Green Deployment Example Run old and new workers simultaneously during deployments: diff --git a/src/DurableTask.Core/Settings/VersioningSettings.cs b/src/DurableTask.Core/Settings/VersioningSettings.cs index 41b00d161..c6c3284f7 100644 --- a/src/DurableTask.Core/Settings/VersioningSettings.cs +++ b/src/DurableTask.Core/Settings/VersioningSettings.cs @@ -12,6 +12,7 @@ // ---------------------------------------------------------------------------------- using System; +using System.Collections.Generic; namespace DurableTask.Core.Settings { @@ -72,6 +73,19 @@ public enum VersionFailureStrategy /// public VersionFailureStrategy FailureStrategy { get; set; } = VersionFailureStrategy.Reject; + /// + /// Gets the orchestration names whose unversioned executions are excluded from worker version checks. + /// + /// + /// This set is empty by default and matches names using an exact, case-sensitive ordinal comparison. + /// An exclusion applies only when the execution version is null or empty; nonempty versions still + /// follow and . + /// Use this for explicitly registered, unversioned infrastructure orchestrations, not business orchestrations. + /// Exclusions do not change execution versions or orchestration registration and lookup. + /// Configure this set before starting the worker and do not modify it while the worker is running. + /// + public ISet ExcludedOrchestrationNames { get; } = new HashSet(StringComparer.Ordinal); + /// /// Compare two versions to each other. /// diff --git a/src/DurableTask.Core/TaskOrchestrationDispatcher.cs b/src/DurableTask.Core/TaskOrchestrationDispatcher.cs index 649e7b47a..679d37774 100644 --- a/src/DurableTask.Core/TaskOrchestrationDispatcher.cs +++ b/src/DurableTask.Core/TaskOrchestrationDispatcher.cs @@ -398,7 +398,9 @@ protected async Task OnProcessWorkItemAsync(TaskOrchestrationWorkItem work IReadOnlyList decisions = new List(); bool versioningFailed = false; - if (this.versioningSettings != null) + if (this.versioningSettings != null && + !(string.IsNullOrEmpty(runtimeState.Version) && + this.versioningSettings.ExcludedOrchestrationNames.Contains(runtimeState.Name))) { switch (this.versioningSettings.MatchStrategy) {