Skip to content

Commit b56628e

Browse files
authored
Allow overriding orchestration version when starting orchestrations via APIs in PowerShell, Python, and Node.js (#3213)
1 parent f9d39a4 commit b56628e

File tree

8 files changed

+221
-16
lines changed

8 files changed

+221
-16
lines changed

release_notes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
### New Features
1616

17-
- Initialize linux logging in Managed Environment (Azure Container Apps) scenarios. Emit MS_DURABLE_FUNCTION_EVENTS_LOGS in these environments.
17+
- 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)
1818

1919
### Bug Fixes
2020

src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableOrchestrationContext.cs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ async Task<DurableHttpResponse> IDurableOrchestrationContext.CallHttpAsync(Durab
310310
private async Task<DurableHttpResponse> ScheduleDurableHttpActivityAsync(DurableHttpRequest req)
311311
{
312312
DurableHttpResponse durableHttpResponse = await this.CallDurableTaskFunctionAsync<DurableHttpResponse>(
313-
functionName: HttpOptions.HttpTaskActivityReservedName,
313+
functionNameWithVersion: HttpOptions.HttpTaskActivityReservedName,
314314
functionType: FunctionType.Activity,
315315
oneWay: false,
316316
instanceId: null,
@@ -538,7 +538,7 @@ string IDurableOrchestrationContext.StartNewOrchestration(string functionName, o
538538
}
539539

540540
internal async Task<TResult> CallDurableTaskFunctionAsync<TResult>(
541-
string functionName,
541+
string functionNameWithVersion,
542542
FunctionType functionType,
543543
bool oneWay,
544544
string instanceId,
@@ -562,11 +562,7 @@ internal async Task<TResult> CallDurableTaskFunctionAsync<TResult>(
562562
}
563563
}
564564

565-
// Propagate the default version to orchestrators.
566-
// TODO: Decide whether we want to propagate the default version to actitities and entities as well.
567-
string version = (functionType == FunctionType.Orchestrator)
568-
? this.Config.Options.DefaultVersion
569-
: string.Empty;
565+
(string functionName, string version) = this.ResolveVersionForFunctionCall(functionNameWithVersion, functionType);
570566

571567
this.Config.ThrowIfFunctionDoesNotExist(functionName, functionType);
572568

@@ -850,6 +846,33 @@ internal async Task<TResult> CallDurableTaskFunctionAsync<TResult>(
850846
return output;
851847
}
852848

849+
/// <summary>
850+
/// Resolves the function name and version for a function call based on the function type.
851+
/// </summary>
852+
/// <param name="functionNameWithVersion">The function name, optionally with version suffix.</param>
853+
/// <param name="functionType">The type of function being called.</param>
854+
/// <returns>A tuple containing the function name and resolved version.</returns>
855+
private (string functionName, string version) ResolveVersionForFunctionCall(
856+
string functionNameWithVersion,
857+
FunctionType functionType)
858+
{
859+
// Only orchestrators support versioning.
860+
// TODO: Decide whether we want to propagate the version to activities and entities as well.
861+
if (functionType != FunctionType.Orchestrator)
862+
{
863+
return (functionNameWithVersion, string.Empty);
864+
}
865+
866+
(string functionName, string version) = FunctionNameWithVersion.Parse(functionNameWithVersion);
867+
if (version is null)
868+
{
869+
// Propagate the default version if no explicit version is provided.
870+
version = this.Config.Options.DefaultVersion;
871+
}
872+
873+
return (functionName, version);
874+
}
875+
853876
internal async Task<TResult> WaitForEntityResponse<TResult>(Guid guid, EntityId? lockToUse)
854877
{
855878
var response = await this.WaitForExternalEvent<ResponseMessage>(guid.ToString(), "EntityResponse");
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
namespace Microsoft.Azure.WebJobs.Extensions.DurableTask
5+
{
6+
/// <summary>
7+
/// Utility class for handling function names with optional version information.
8+
/// </summary>
9+
internal static class FunctionNameWithVersion
10+
{
11+
/// <summary>
12+
/// Delimiter used to separate function name from version in serialized format.
13+
/// </summary>
14+
internal const char Delimiter = '\n';
15+
16+
/// <summary>
17+
/// Combines a function name and optional version into a single string.
18+
/// </summary>
19+
/// <param name="functionName">The name of the function.</param>
20+
/// <param name="version">The optional version string. If null, only the function name is returned.</param>
21+
/// <returns>The combined function name and version string, or just the function name if version is null.</returns>
22+
internal static string Combine(string functionName, string version)
23+
{
24+
return version == null ? functionName : functionName + Delimiter + version;
25+
}
26+
27+
/// <summary>
28+
/// Parses a combined function name and version string into separate components.
29+
/// </summary>
30+
/// <param name="functionNameAndVersion">The combined function name and version string.</param>
31+
/// <returns>A tuple containing the function name and version. Version will be null if no delimiter is found.</returns>
32+
internal static (string functionName, string version) Parse(string functionNameAndVersion)
33+
{
34+
int delimiterIndex = functionNameAndVersion.IndexOf(Delimiter);
35+
if (delimiterIndex < 0)
36+
{
37+
// No version specified
38+
return (functionNameAndVersion, null);
39+
}
40+
41+
// Function name and version are separated by delimiter
42+
var functionName = functionNameAndVersion.Substring(0, delimiterIndex);
43+
var version = functionNameAndVersion.Substring(delimiterIndex + 1);
44+
return (functionName, version);
45+
}
46+
}
47+
}

src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ internal class HttpApiHandler : IDisposable
6363
private const string PollingInterval = "pollingInterval";
6464
private const string SuspendOperation = "suspend";
6565
private const string ResumeOperation = "resume";
66+
private const string VersionParameter = "version";
6667

6768
private const string EmptyEntityKeySymbol = "$";
6869

@@ -887,12 +888,14 @@ private async Task<HttpResponseMessage> HandleStartOrchestratorRequestAsync(
887888
ExecutionId = Guid.NewGuid().ToString(),
888889
};
889890

891+
var version = queryNameValuePairs[VersionParameter] ?? this.config.Options.DefaultVersion;
892+
890893
// Create the ExecutionStartedEvent
891894
ExecutionStartedEvent executionStartedEvent = new ExecutionStartedEvent(-1, json)
892895
{
893896
Name = functionName,
894897
OrchestrationInstance = instance,
895-
Version = this.config.Options.DefaultVersion,
898+
Version = version,
896899
};
897900

898901
string traceParent = GetHeaderValueFromHeaders("traceparent", request.Headers);

src/WebJobs.Extensions.DurableTask/Listener/OutOfProcOrchestrationShim.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,10 @@ private Task InvokeAPIFromAction(AsyncAction action, SchemaVersion schema)
137137
task = this.context.CallActivityWithRetryAsync(action.FunctionName, action.RetryOptions, action.Input);
138138
break;
139139
case AsyncActionType.CallSubOrchestrator:
140-
task = this.context.CallSubOrchestratorAsync(action.FunctionName, action.InstanceId, action.Input);
140+
task = this.context.CallSubOrchestratorAsync(FunctionNameWithVersion.Combine(action.FunctionName, action.Version), action.InstanceId, action.Input);
141141
break;
142142
case AsyncActionType.CallSubOrchestratorWithRetry:
143-
task = this.context.CallSubOrchestratorWithRetryAsync(action.FunctionName, action.RetryOptions, action.InstanceId, action.Input);
143+
task = this.context.CallSubOrchestratorWithRetryAsync(FunctionNameWithVersion.Combine(action.FunctionName, action.Version), action.RetryOptions, action.InstanceId, action.Input);
144144
break;
145145
case AsyncActionType.CallEntity:
146146
{
@@ -310,6 +310,9 @@ private class AsyncAction
310310
[JsonProperty("functionName")]
311311
internal string FunctionName { get; set; }
312312

313+
[JsonProperty("version")]
314+
internal string Version { get; set; }
315+
313316
[JsonProperty("input")]
314317
internal object Input { get; set; }
315318

test/Common/HttpApiHandlerTests.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1673,14 +1673,21 @@ public async Task GetClientResponseLinks_Ignores_Forwarded_Headers_When_Disabled
16731673
Assert.StartsWith("http://localhost:7071", (string)status["terminatePostUri"]);
16741674
}
16751675

1676-
[Fact]
1676+
[Theory]
1677+
[InlineData(null, null, null)] // No default, no query parameter
1678+
[InlineData("4.0", null, "4.0")] // Default version used when no query parameter
1679+
[InlineData("4.0", "5.2", "5.2")] // Query parameter overrides default
1680+
[InlineData("4.0", "", "")] // Empty query parameter overrides default
16771681
[Trait("Category", PlatformSpecificHelpers.TestCategory)]
1678-
public async Task StartNewInstance_Uses_DefaultVersion_And_Calls_CreateTaskOrchestrationAsync()
1682+
public async Task StartNewInstance_Calls_CreateTaskOrchestrationAsync_With_Correct_Version(
1683+
string defaultVersion, string queryParameterVersion, string expectedVersion)
16791684
{
16801685
var functionName = "TestOrchestrator";
16811686
var instanceId = Guid.NewGuid().ToString("N");
1682-
var defaultVersion = "4.0";
1683-
var requestUri = new Uri($"http://localhost/runtime/webhooks/durabletask/orchestrators/{functionName}/{instanceId}");
1687+
var baseUri = $"http://localhost/runtime/webhooks/durabletask/orchestrators/{functionName}/{instanceId}";
1688+
var requestUri = queryParameterVersion != null
1689+
? new Uri($"{baseUri}?version={queryParameterVersion}")
1690+
: new Uri(baseUri);
16841691

16851692
ExecutionStartedEvent capturedEvent = null;
16861693

@@ -1725,7 +1732,7 @@ public async Task StartNewInstance_Uses_DefaultVersion_And_Calls_CreateTaskOrche
17251732

17261733
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
17271734
Assert.NotNull(capturedEvent);
1728-
Assert.Equal(defaultVersion, capturedEvent.Version);
1735+
Assert.Equal(expectedVersion, capturedEvent.Version);
17291736
}
17301737

17311738
private static DurableTaskExtension GetTestExtension()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
using Xunit;
5+
6+
namespace Microsoft.Azure.WebJobs.Extensions.DurableTask.Tests
7+
{
8+
public class FunctionNameWithVersionTests
9+
{
10+
[Theory]
11+
[InlineData("MyOrchestrator", null)] // Without version
12+
[InlineData("MyOrchestrator", "v2.5.1")] // With version
13+
[InlineData("Function", "1.0.0")] // Semantic version with major.minor.patch
14+
[InlineData("Function", "")] // Empty string version
15+
[InlineData("Complex_Name-123", "v3.2.1-beta+build")] // Complex names and versions
16+
[Trait("Category", PlatformSpecificHelpers.TestCategory)]
17+
public void Combine_And_Parse_PreserveValues(string originalName, string originalVersion)
18+
{
19+
string combined = FunctionNameWithVersion.Combine(originalName, originalVersion);
20+
(string parsedName, string parsedVersion) = FunctionNameWithVersion.Parse(combined);
21+
22+
Assert.Equal(originalName, parsedName);
23+
if (originalVersion == null)
24+
{
25+
Assert.Null(parsedVersion);
26+
}
27+
else
28+
{
29+
Assert.Equal(originalVersion, parsedVersion);
30+
}
31+
}
32+
}
33+
}

test/FunctionsV2/OutOfProcTests.cs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,5 +415,94 @@ public void WorkerRuntimeTypeFollowsSpec(string workerRuntime)
415415
runtimeType.ToString().Equals(workerRuntime, StringComparison.OrdinalIgnoreCase);
416416
}
417417
}
418+
419+
[Theory]
420+
[InlineData(false, "2.0", null, "MySubOrchestrator\n2.0")] // Explicit version
421+
[InlineData(false, null, null, "MySubOrchestrator")] // Null version - no delimiter
422+
[InlineData(false, "", null, "MySubOrchestrator\n")] // Empty version - delimiter included
423+
[InlineData(false, "1.0.0", null, "MySubOrchestrator\n1.0.0")] // Semantic version
424+
[InlineData(false, "4.5.6-preview", null, "MySubOrchestrator\n4.5.6-preview")] // Pre-release version
425+
[InlineData(false, "2.0-beta.1", null, "MySubOrchestrator\n2.0-beta.1")] // Beta version
426+
[InlineData(false, "v1.2.3", null, "MySubOrchestrator\nv1.2.3")] // Version with prefix
427+
[InlineData(true, "3.5.1", null, "MySubOrchestrator\n3.5.1")] // Explicit version with retry
428+
[InlineData(true, null, null, "MySubOrchestrator")] // Null version with retry
429+
[InlineData(false, "5.0", "V2", "MySubOrchestrator\n5.0")] // Schema V2
430+
[InlineData(false, "5.0", "V3", "MySubOrchestrator\n5.0")] // Schema V3
431+
[Trait("Category", PlatformSpecificHelpers.TestCategory)]
432+
public async Task CallSubOrchestrator_VersionHandling_OutOfProc(bool withRetry, string version, string schemaVersion, string expectedFunctionName)
433+
{
434+
string capturedFunctionName = null;
435+
RetryOptions capturedRetryOptions = null;
436+
437+
// Mock the CallSubOrchestratorAsync or CallSubOrchestratorWithRetryAsync API
438+
var contextMock = new Mock<IDurableOrchestrationContext>();
439+
440+
if (withRetry)
441+
{
442+
contextMock
443+
.Setup(ctx => ctx.CallSubOrchestratorWithRetryAsync(
444+
It.IsAny<string>(),
445+
It.IsAny<RetryOptions>(),
446+
It.IsAny<string>(),
447+
It.IsAny<object>()))
448+
.Callback<string, RetryOptions, string, object>((name, retry, instanceId, input) =>
449+
{
450+
capturedFunctionName = name;
451+
capturedRetryOptions = retry;
452+
})
453+
.Returns(Task.CompletedTask);
454+
}
455+
else
456+
{
457+
contextMock
458+
.Setup(ctx => ctx.CallSubOrchestratorAsync(
459+
It.IsAny<string>(),
460+
It.IsAny<string>(),
461+
It.IsAny<object>()))
462+
.Callback<string, string, object>((name, instanceId, input) =>
463+
capturedFunctionName = name)
464+
.Returns(Task.CompletedTask);
465+
}
466+
467+
var shim = new OutOfProcOrchestrationShim(contextMock.Object);
468+
469+
var actionType = withRetry ? "CallSubOrchestratorWithRetry" : "CallSubOrchestrator";
470+
var versionField = version == null ? string.Empty : $@"""version"": ""{version}"",";
471+
var schemaVersionField = schemaVersion == null ? string.Empty : $@"""schemaVersion"": ""{schemaVersion}"",";
472+
var retryField = withRetry ? @"""retryOptions"": {
473+
""firstRetryIntervalInMilliseconds"": 1000,
474+
""maxNumberOfAttempts"": 3
475+
}," : string.Empty;
476+
477+
var executionJson = $@"
478+
{{
479+
""isDone"": false,
480+
{schemaVersionField}
481+
""actions"": [
482+
[{{
483+
""actionType"": ""{actionType}"",
484+
""functionName"": ""MySubOrchestrator"",
485+
{versionField}
486+
""instanceId"": ""test-instance"",
487+
{retryField}
488+
""input"": null
489+
}}]
490+
]
491+
}}";
492+
493+
var jsonObject = JObject.Parse(executionJson);
494+
OrchestrationInvocationResult result = new OrchestrationInvocationResult(jsonObject);
495+
bool moreWork = await shim.ScheduleDurableTaskEvents(result);
496+
497+
Assert.True(moreWork);
498+
Assert.NotNull(capturedFunctionName);
499+
Assert.Equal(expectedFunctionName, capturedFunctionName);
500+
501+
if (withRetry)
502+
{
503+
Assert.NotNull(capturedRetryOptions);
504+
Assert.Equal(3, capturedRetryOptions.MaxNumberOfAttempts);
505+
}
506+
}
418507
}
419508
}

0 commit comments

Comments
 (0)