Skip to content
Open
Show file tree
Hide file tree
Changes from all 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