Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ async Task<DurableHttpResponse> IDurableOrchestrationContext.CallHttpAsync(Durab
private async Task<DurableHttpResponse> ScheduleDurableHttpActivityAsync(DurableHttpRequest req)
{
DurableHttpResponse durableHttpResponse = await this.CallDurableTaskFunctionAsync<DurableHttpResponse>(
functionName: HttpOptions.HttpTaskActivityReservedName,
functionNameWithVersion: HttpOptions.HttpTaskActivityReservedName,
functionType: FunctionType.Activity,
oneWay: false,
instanceId: null,
Expand Down Expand Up @@ -538,7 +538,7 @@ string IDurableOrchestrationContext.StartNewOrchestration(string functionName, o
}

internal async Task<TResult> CallDurableTaskFunctionAsync<TResult>(
string functionName,
string functionNameWithVersion,
FunctionType functionType,
bool oneWay,
string instanceId,
Expand All @@ -562,11 +562,7 @@ internal async Task<TResult> CallDurableTaskFunctionAsync<TResult>(
}
}

// 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);

Expand Down Expand Up @@ -850,6 +846,33 @@ internal async Task<TResult> CallDurableTaskFunctionAsync<TResult>(
return output;
}

/// <summary>
/// Resolves the function name and version for a function call based on the function type.
/// </summary>
/// <param name="functionNameWithVersion">The function name, optionally with version suffix.</param>
/// <param name="functionType">The type of function being called.</param>
/// <returns>A tuple containing the function name and resolved version.</returns>
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<TResult> WaitForEntityResponse<TResult>(Guid guid, EntityId? lockToUse)
{
var response = await this.WaitForExternalEvent<ResponseMessage>(guid.ToString(), "EntityResponse");
Expand Down
47 changes: 47 additions & 0 deletions src/WebJobs.Extensions.DurableTask/FunctionNameWithVersion.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Utility class for handling function names with optional version information.
/// </summary>
internal static class FunctionNameWithVersion
{
/// <summary>
/// Delimiter used to separate function name from version in serialized format.
/// </summary>
internal const char Delimiter = '\n';

/// <summary>
/// Combines a function name and optional version into a single string.
/// </summary>
/// <param name="functionName">The name of the function.</param>
/// <param name="version">The optional version string. If null, only the function name is returned.</param>
/// <returns>The combined function name and version string, or just the function name if version is null.</returns>
internal static string Combine(string functionName, string version)
{
return version == null ? functionName : functionName + Delimiter + version;
}

/// <summary>
/// Parses a combined function name and version string into separate components.
/// </summary>
/// <param name="functionNameAndVersion">The combined function name and version string.</param>
/// <returns>A tuple containing the function name and version. Version will be null if no delimiter is found.</returns>
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);
}
}
}
5 changes: 4 additions & 1 deletion src/WebJobs.Extensions.DurableTask/HttpApiHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "$";

Expand Down Expand Up @@ -887,12 +888,14 @@ private async Task<HttpResponseMessage> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
{
Expand Down Expand Up @@ -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; }

Expand Down
17 changes: 12 additions & 5 deletions test/Common/HttpApiHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()
Expand Down
33 changes: 33 additions & 0 deletions test/FunctionsV2/FunctionNameWithVersionTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
89 changes: 89 additions & 0 deletions test/FunctionsV2/OutOfProcTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDurableOrchestrationContext>();

if (withRetry)
{
contextMock
.Setup(ctx => ctx.CallSubOrchestratorWithRetryAsync(
It.IsAny<string>(),
It.IsAny<RetryOptions>(),
It.IsAny<string>(),
It.IsAny<object>()))
.Callback<string, RetryOptions, string, object>((name, retry, instanceId, input) =>
{
capturedFunctionName = name;
capturedRetryOptions = retry;
})
.Returns(Task.CompletedTask);
}
else
{
contextMock
.Setup(ctx => ctx.CallSubOrchestratorAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object>()))
.Callback<string, string, object>((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);
}
}
}
}
Loading