diff --git a/release_notes.md b/release_notes.md index ecf2ce1f0..4db7e8f13 100644 --- a/release_notes.md +++ b/release_notes.md @@ -14,7 +14,7 @@ ### New Features -- Initialize linux logging in Managed Environment (Azure Container Apps) scenarios. Emit MS_DURABLE_FUNCTION_EVENTS_LOGS in these environments. +- Allow overriding orchestration version when starting orchestrations via APIs in PowerShell, Python, and Node.js (https://github.com/Azure/azure-functions-durable-extension/pull/3213) ### Bug Fixes diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableOrchestrationContext.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableOrchestrationContext.cs index 485c7210b..0c1eb6e66 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableOrchestrationContext.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableOrchestrationContext.cs @@ -310,7 +310,7 @@ async Task IDurableOrchestrationContext.CallHttpAsync(Durab private async Task ScheduleDurableHttpActivityAsync(DurableHttpRequest req) { DurableHttpResponse durableHttpResponse = await this.CallDurableTaskFunctionAsync( - functionName: HttpOptions.HttpTaskActivityReservedName, + functionNameWithVersion: HttpOptions.HttpTaskActivityReservedName, functionType: FunctionType.Activity, oneWay: false, instanceId: null, @@ -538,7 +538,7 @@ string IDurableOrchestrationContext.StartNewOrchestration(string functionName, o } internal async Task CallDurableTaskFunctionAsync( - string functionName, + string functionNameWithVersion, FunctionType functionType, bool oneWay, string instanceId, @@ -562,11 +562,7 @@ internal async Task CallDurableTaskFunctionAsync( } } - // Propagate the default version to orchestrators. - // TODO: Decide whether we want to propagate the default version to actitities and entities as well. - string version = (functionType == FunctionType.Orchestrator) - ? this.Config.Options.DefaultVersion - : string.Empty; + (string functionName, string version) = this.ResolveVersionForFunctionCall(functionNameWithVersion, functionType); this.Config.ThrowIfFunctionDoesNotExist(functionName, functionType); @@ -850,6 +846,33 @@ internal async Task CallDurableTaskFunctionAsync( return output; } + /// + /// Resolves the function name and version for a function call based on the function type. + /// + /// The function name, optionally with version suffix. + /// The type of function being called. + /// A tuple containing the function name and resolved version. + private (string functionName, string version) ResolveVersionForFunctionCall( + string functionNameWithVersion, + FunctionType functionType) + { + // Only orchestrators support versioning. + // TODO: Decide whether we want to propagate the version to activities and entities as well. + if (functionType != FunctionType.Orchestrator) + { + return (functionNameWithVersion, null); + } + + (string functionName, string version) = FunctionNameWithVersion.Parse(functionNameWithVersion); + if (version is null) + { + // Propagate the default version if no explicit version is provided. + version = this.Config.Options.DefaultVersion; + } + + return (functionName, version); + } + internal async Task WaitForEntityResponse(Guid guid, EntityId? lockToUse) { var response = await this.WaitForExternalEvent(guid.ToString(), "EntityResponse"); diff --git a/src/WebJobs.Extensions.DurableTask/FunctionNameWithVersion.cs b/src/WebJobs.Extensions.DurableTask/FunctionNameWithVersion.cs new file mode 100644 index 000000000..f50a4e207 --- /dev/null +++ b/src/WebJobs.Extensions.DurableTask/FunctionNameWithVersion.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask +{ + /// + /// Utility class for handling function names with optional version information. + /// + internal static class FunctionNameWithVersion + { + /// + /// Delimiter used to separate function name from version in serialized format. + /// + internal const char Delimiter = '\n'; + + /// + /// Combines a function name and optional version into a single string. + /// + /// The name of the function. + /// The optional version string. If null, only the function name is returned. + /// The combined function name and version string, or just the function name if version is null. + internal static string Combine(string functionName, string version) + { + return version == null ? functionName : functionName + Delimiter + version; + } + + /// + /// Parses a combined function name and version string into separate components. + /// + /// The combined function name and version string. + /// A tuple containing the function name and version. Version will be null if no delimiter is found. + internal static (string functionName, string version) Parse(string functionNameAndVersion) + { + int delimiterIndex = functionNameAndVersion.IndexOf(Delimiter); + if (delimiterIndex < 0) + { + // No version specified + return (functionNameAndVersion, null); + } + + // Function name and version are separated by delimiter + var functionName = functionNameAndVersion.Substring(0, delimiterIndex); + var version = functionNameAndVersion.Substring(delimiterIndex + 1); + return (functionName, version); + } + } +} diff --git a/src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs b/src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs index d9e586d78..daa795dca 100644 --- a/src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs +++ b/src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs @@ -63,6 +63,7 @@ internal class HttpApiHandler : IDisposable private const string PollingInterval = "pollingInterval"; private const string SuspendOperation = "suspend"; private const string ResumeOperation = "resume"; + private const string VersionParameter = "version"; private const string EmptyEntityKeySymbol = "$"; @@ -887,12 +888,14 @@ private async Task HandleStartOrchestratorRequestAsync( ExecutionId = Guid.NewGuid().ToString(), }; + var version = queryNameValuePairs[VersionParameter] ?? this.config.Options.DefaultVersion; + // Create the ExecutionStartedEvent ExecutionStartedEvent executionStartedEvent = new ExecutionStartedEvent(-1, json) { Name = functionName, OrchestrationInstance = instance, - Version = this.config.Options.DefaultVersion, + Version = version, }; string traceParent = GetHeaderValueFromHeaders("traceparent", request.Headers); diff --git a/src/WebJobs.Extensions.DurableTask/Listener/OutOfProcOrchestrationShim.cs b/src/WebJobs.Extensions.DurableTask/Listener/OutOfProcOrchestrationShim.cs index 47d7c9092..14ceeef11 100644 --- a/src/WebJobs.Extensions.DurableTask/Listener/OutOfProcOrchestrationShim.cs +++ b/src/WebJobs.Extensions.DurableTask/Listener/OutOfProcOrchestrationShim.cs @@ -137,10 +137,10 @@ private Task InvokeAPIFromAction(AsyncAction action, SchemaVersion schema) task = this.context.CallActivityWithRetryAsync(action.FunctionName, action.RetryOptions, action.Input); break; case AsyncActionType.CallSubOrchestrator: - task = this.context.CallSubOrchestratorAsync(action.FunctionName, action.InstanceId, action.Input); + task = this.context.CallSubOrchestratorAsync(FunctionNameWithVersion.Combine(action.FunctionName, action.Version), action.InstanceId, action.Input); break; case AsyncActionType.CallSubOrchestratorWithRetry: - task = this.context.CallSubOrchestratorWithRetryAsync(action.FunctionName, action.RetryOptions, action.InstanceId, action.Input); + task = this.context.CallSubOrchestratorWithRetryAsync(FunctionNameWithVersion.Combine(action.FunctionName, action.Version), action.RetryOptions, action.InstanceId, action.Input); break; case AsyncActionType.CallEntity: { @@ -310,6 +310,9 @@ private class AsyncAction [JsonProperty("functionName")] internal string FunctionName { get; set; } + [JsonProperty("version")] + internal string Version { get; set; } + [JsonProperty("input")] internal object Input { get; set; } diff --git a/test/Common/HttpApiHandlerTests.cs b/test/Common/HttpApiHandlerTests.cs index 78c210e66..70128ca02 100644 --- a/test/Common/HttpApiHandlerTests.cs +++ b/test/Common/HttpApiHandlerTests.cs @@ -1673,14 +1673,21 @@ public async Task GetClientResponseLinks_Ignores_Forwarded_Headers_When_Disabled Assert.StartsWith("http://localhost:7071", (string)status["terminatePostUri"]); } - [Fact] + [Theory] + [InlineData(null, null, null)] // No default, no query parameter + [InlineData("4.0", null, "4.0")] // Default version used when no query parameter + [InlineData("4.0", "5.2", "5.2")] // Query parameter overrides default + [InlineData("4.0", "", "")] // Empty query parameter overrides default [Trait("Category", PlatformSpecificHelpers.TestCategory)] - public async Task StartNewInstance_Uses_DefaultVersion_And_Calls_CreateTaskOrchestrationAsync() + public async Task StartNewInstance_Calls_CreateTaskOrchestrationAsync_With_Correct_Version( + string defaultVersion, string queryParameterVersion, string expectedVersion) { var functionName = "TestOrchestrator"; var instanceId = Guid.NewGuid().ToString("N"); - var defaultVersion = "4.0"; - var requestUri = new Uri($"http://localhost/runtime/webhooks/durabletask/orchestrators/{functionName}/{instanceId}"); + var baseUri = $"http://localhost/runtime/webhooks/durabletask/orchestrators/{functionName}/{instanceId}"; + var requestUri = queryParameterVersion != null + ? new Uri($"{baseUri}?version={queryParameterVersion}") + : new Uri(baseUri); ExecutionStartedEvent capturedEvent = null; @@ -1725,7 +1732,7 @@ public async Task StartNewInstance_Uses_DefaultVersion_And_Calls_CreateTaskOrche Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.NotNull(capturedEvent); - Assert.Equal(defaultVersion, capturedEvent.Version); + Assert.Equal(expectedVersion, capturedEvent.Version); } private static DurableTaskExtension GetTestExtension() diff --git a/test/FunctionsV2/FunctionNameWithVersionTests.cs b/test/FunctionsV2/FunctionNameWithVersionTests.cs new file mode 100644 index 000000000..e516daf60 --- /dev/null +++ b/test/FunctionsV2/FunctionNameWithVersionTests.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Xunit; + +namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests +{ + public class FunctionNameWithVersionTests + { + [Theory] + [InlineData("MyOrchestrator", null)] // Without version + [InlineData("MyOrchestrator", "v2.5.1")] // With version + [InlineData("Function", "1.0.0")] // Semantic version with major.minor.patch + [InlineData("Function", "")] // Empty string version + [InlineData("Complex_Name-123", "v3.2.1-beta+build")] // Complex names and versions + [Trait("Category", PlatformSpecificHelpers.TestCategory)] + public void Combine_And_Parse_PreserveValues(string originalName, string originalVersion) + { + string combined = FunctionNameWithVersion.Combine(originalName, originalVersion); + (string parsedName, string parsedVersion) = FunctionNameWithVersion.Parse(combined); + + Assert.Equal(originalName, parsedName); + if (originalVersion == null) + { + Assert.Null(parsedVersion); + } + else + { + Assert.Equal(originalVersion, parsedVersion); + } + } + } +} diff --git a/test/FunctionsV2/OutOfProcTests.cs b/test/FunctionsV2/OutOfProcTests.cs index e459a324c..d8dbb20c4 100644 --- a/test/FunctionsV2/OutOfProcTests.cs +++ b/test/FunctionsV2/OutOfProcTests.cs @@ -415,5 +415,94 @@ public void WorkerRuntimeTypeFollowsSpec(string workerRuntime) runtimeType.ToString().Equals(workerRuntime, StringComparison.OrdinalIgnoreCase); } } + + [Theory] + [InlineData(false, "2.0", null, "MySubOrchestrator\n2.0")] // Explicit version + [InlineData(false, null, null, "MySubOrchestrator")] // Null version - no delimiter + [InlineData(false, "", null, "MySubOrchestrator\n")] // Empty version - delimiter included + [InlineData(false, "1.0.0", null, "MySubOrchestrator\n1.0.0")] // Semantic version + [InlineData(false, "4.5.6-preview", null, "MySubOrchestrator\n4.5.6-preview")] // Pre-release version + [InlineData(false, "2.0-beta.1", null, "MySubOrchestrator\n2.0-beta.1")] // Beta version + [InlineData(false, "v1.2.3", null, "MySubOrchestrator\nv1.2.3")] // Version with prefix + [InlineData(true, "3.5.1", null, "MySubOrchestrator\n3.5.1")] // Explicit version with retry + [InlineData(true, null, null, "MySubOrchestrator")] // Null version with retry + [InlineData(false, "5.0", "V2", "MySubOrchestrator\n5.0")] // Schema V2 + [InlineData(false, "5.0", "V3", "MySubOrchestrator\n5.0")] // Schema V3 + [Trait("Category", PlatformSpecificHelpers.TestCategory)] + public async Task CallSubOrchestrator_VersionHandling_OutOfProc(bool withRetry, string version, string schemaVersion, string expectedFunctionName) + { + string capturedFunctionName = null; + RetryOptions capturedRetryOptions = null; + + // Mock the CallSubOrchestratorAsync or CallSubOrchestratorWithRetryAsync API + var contextMock = new Mock(); + + if (withRetry) + { + contextMock + .Setup(ctx => ctx.CallSubOrchestratorWithRetryAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((name, retry, instanceId, input) => + { + capturedFunctionName = name; + capturedRetryOptions = retry; + }) + .Returns(Task.CompletedTask); + } + else + { + contextMock + .Setup(ctx => ctx.CallSubOrchestratorAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((name, instanceId, input) => + capturedFunctionName = name) + .Returns(Task.CompletedTask); + } + + var shim = new OutOfProcOrchestrationShim(contextMock.Object); + + var actionType = withRetry ? "CallSubOrchestratorWithRetry" : "CallSubOrchestrator"; + var versionField = version == null ? string.Empty : $@"""version"": ""{version}"","; + var schemaVersionField = schemaVersion == null ? string.Empty : $@"""schemaVersion"": ""{schemaVersion}"","; + var retryField = withRetry ? @"""retryOptions"": { + ""firstRetryIntervalInMilliseconds"": 1000, + ""maxNumberOfAttempts"": 3 + }," : string.Empty; + + var executionJson = $@" +{{ + ""isDone"": false, + {schemaVersionField} + ""actions"": [ + [{{ + ""actionType"": ""{actionType}"", + ""functionName"": ""MySubOrchestrator"", + {versionField} + ""instanceId"": ""test-instance"", + {retryField} + ""input"": null + }}] + ] +}}"; + + var jsonObject = JObject.Parse(executionJson); + OrchestrationInvocationResult result = new OrchestrationInvocationResult(jsonObject); + bool moreWork = await shim.ScheduleDurableTaskEvents(result); + + Assert.True(moreWork); + Assert.NotNull(capturedFunctionName); + Assert.Equal(expectedFunctionName, capturedFunctionName); + + if (withRetry) + { + Assert.NotNull(capturedRetryOptions); + Assert.Equal(3, capturedRetryOptions.MaxNumberOfAttempts); + } + } } }