Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update ContextReference & DefaultFromBodyConversionFeature to remove invocation cancellation token usage #2894

Merged
merged 18 commits into from
Jan 23, 2025
Merged
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
15 changes: 14 additions & 1 deletion eng/ci/templates/jobs/run-integration-tests-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,20 @@ jobs:
displayName: 'Run E2E Tests'
inputs:
command: test
arguments: -v n --no-build -c Release
arguments: -v n --no-build -c Release --filter "FullyQualifiedName!~Microsoft.Azure.Functions.Worker.E2ETests.AspNetCore" # skip AspNetCore tests
projects: |
**\E2ETests.csproj
**\SdkE2ETests.csproj
env:
DOTNET_VERSION: $(dotnetVersion)

- task: DotNetCoreCLI@2
displayName: 'Run E2E AspNetCore Tests'
condition: ne(variables['dotnetVersion'], 'netfx') # Skip if dotnetVersion is netfx
inputs:
command: test
arguments: -v n --no-build -c Release --filter "FullyQualifiedName~Microsoft.Azure.Functions.Worker.E2ETests.AspNetCore" # only AspNetCore tests
projects: |
**\E2ETests.csproj
env:
DOTNET_VERSION: $(dotnetVersion)
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

### Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore <version>

- <entry>
- Update `ContextReference` to no longer use a given invocation's cancellation token (#2894)

### Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Analyzers <version>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,14 @@

namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore
{
internal class ContextReference : IDisposable
internal class ContextReference
{
private readonly TaskCompletionSource<bool> _functionStartTask = new();
private readonly TaskCompletionSource<bool> _functionCompletionTask = new();

private TaskCompletionSource<HttpContext> _httpContextValueSource = new();
private TaskCompletionSource<FunctionContext> _functionContextValueSource = new();

private CancellationToken _token;
private CancellationTokenRegistration _tokenRegistration;

public ContextReference(string invocationId)
{
InvocationId = invocationId;
Expand All @@ -32,18 +29,6 @@ public ContextReference(string invocationId)

public TaskCompletionSource<FunctionContext> FunctionContextValueSource { get => _functionContextValueSource; set => _functionContextValueSource = value; }

internal void SetCancellationToken(CancellationToken token)
{
_token = token;
_tokenRegistration = _token.Register(() =>
{
_functionStartTask.TrySetCanceled();
_functionCompletionTask.TrySetCanceled();
_functionContextValueSource.TrySetCanceled();
_httpContextValueSource.TrySetCanceled();
});
}

internal Task InvokeFunctionAsync()
{
_functionStartTask.SetResult(true);
Expand All @@ -59,27 +44,12 @@ internal void CompleteFunction()

if (_httpContextValueSource.Task.IsCompleted)
{
if (_httpContextValueSource.Task.IsCanceled || _token.IsCancellationRequested)
{
_functionCompletionTask.TrySetCanceled();
}
else
{
_functionCompletionTask.TrySetResult(true);
}
_functionCompletionTask.TrySetResult(true);
}
else
{
// we should never reach here b/c the class that calls this needs httpContextValueSource to complete to reach this method
}
}

public void Dispose()
{
if (_tokenRegistration != default)
{
_tokenRegistration.Dispose();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
Expand Down Expand Up @@ -44,7 +44,6 @@ public async Task<FunctionContext> SetHttpContextAsync(string invocationId, Http
public async Task<HttpContext> SetFunctionContextAsync(string invocationId, FunctionContext context)
{
var contextRef = _contextReferenceList.GetOrAdd(invocationId, static id => new ContextReference(id));
contextRef.SetCancellationToken(context.CancellationToken);
contextRef.FunctionContextValueSource.SetResult(context);

_logger.FunctionContextSet(invocationId);
Expand Down Expand Up @@ -85,7 +84,6 @@ public void CompleteFunctionInvocation(string invocationId)
if (_contextReferenceList.TryRemove(invocationId, out var contextRef))
{
contextRef.CompleteFunction();
contextRef.Dispose();
}
else
{
Expand Down
1 change: 1 addition & 0 deletions extensions/Worker.Extensions.Http/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
### Microsoft.Azure.Functions.Worker.Extensions.Http <version>

- The 'FromBody' converter now utilizes `DeserializeAsync` for deserializing JSON content from the request body, enhancing support for asynchronous deserialization. (#2901)
- Update `DefaultFromBodyConversionFeature` to no longer use a given invocation's cancellation token (#2894)
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ internal class DefaultFromBodyConversionFeature : IFromBodyConversionFeature
_ when HasJsonContentType(requestData) =>
await (requestData.FunctionContext.InstanceServices.GetService<IOptions<WorkerOptions>>()?.Value?.Serializer
?? throw new InvalidOperationException("A serializer is not configured for the worker."))
.DeserializeAsync(requestData.Body, targetType, context.CancellationToken),
.DeserializeAsync(requestData.Body, targetType, CancellationToken.None),
_ => throw new InvalidOperationException($"The type '{targetType}' is not supported by the '{nameof(DefaultFromBodyConversionFeature)}'.")
};

Expand Down
2 changes: 1 addition & 1 deletion setup-e2e-tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ if ($SkipBuildOnPack -eq $true)
$AdditionalPackArgs += "--no-build"
}

.\tools\devpack.ps1 -E2E -AdditionalPackArgs $AdditionalPackArgs
.\tools\devpack.ps1 -DotnetVersion $DotnetVersion -E2E -AdditionalPackArgs $AdditionalPackArgs

if ($SkipStorageEmulator -And $SkipCosmosDBEmulator)
{
Expand Down
2 changes: 1 addition & 1 deletion test/E2ETests/E2EApps/E2EApp/local.settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"IsEncrypted": false,
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;

namespace Microsoft.Azure.Functions.Worker.E2EApp
{
public class CancellationHttpFunctions(ILogger<CancellationHttpFunctions> logger)
{
private readonly ILogger<CancellationHttpFunctions> _logger = logger;

[Function(nameof(HttpWithCancellationTokenNotUsed))]
public async Task<IActionResult> HttpWithCancellationTokenNotUsed(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequest req)
{
_logger.LogInformation("HttpWithCancellationTokenNotUsed processed a request.");

await SimulateWork(CancellationToken.None);

return new OkObjectResult("Processing completed successfully.");
}

[Function(nameof(HttpWithCancellationTokenIgnored))]
public async Task<IActionResult> HttpWithCancellationTokenIgnored(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequest req,
CancellationToken cancellationToken)
{
_logger.LogInformation("HttpWithCancellationTokenIgnored processed a request.");

await SimulateWork(cancellationToken);

return new OkObjectResult("Processing completed successfully.");
}

[Function(nameof(HttpWithCancellationTokenHandled))]
public async Task<IActionResult> HttpWithCancellationTokenHandled(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
CancellationToken cancellationToken)
{
_logger.LogInformation("HttpWithCancellationTokenHandled processed a request.");

try
{
await SimulateWork(cancellationToken);

return new OkObjectResult("Processing completed successfully.");
}
catch (OperationCanceledException)
{
_logger.LogWarning("Request was cancelled.");

// Take precautions like noting how far along you are with processing the batch
await Task.Delay(1000);

return new ObjectResult(new { statusCode = StatusCodes.Status499ClientClosedRequest, message = "Request was cancelled." });
}
}

private async Task SimulateWork(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting work...");

for (int i = 0; i < 5; i++)
{
// Simulate work
await Task.Delay(1000, cancellationToken);
_logger.LogWarning($"Work iteration {i + 1} completed.");
}

_logger.LogInformation("Work completed.");
}
}
}
32 changes: 32 additions & 0 deletions test/E2ETests/E2EApps/E2EAspNetCoreApp/E2EAspNetCoreApp.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Microsoft.Azure.Functions.Worker.E2EAspNetCoreApp</AssemblyName>
<RootNamespace>Microsoft.Azure.Functions.Worker.E2EAspNetCoreApp</RootNamespace>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\..\extensions\Worker.Extensions.Http\src\Worker.Extensions.Http.csproj" />
<ProjectReference Include="..\..\..\..\extensions\Worker.Extensions.Http.AspNetCore\src\Worker.Extensions.Http.AspNetCore.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.0" />
<PackageReference Condition="$(TestBuild) != 'true'" Include="Microsoft.Azure.Functions.Worker" Version="2.0.0" />
</ItemGroup>
</Project>
7 changes: 7 additions & 0 deletions test/E2ETests/E2EApps/E2EAspNetCoreApp/NuGet.Config
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="AzureFunctionsTempStaging" value="https://azfunc.pkgs.visualstudio.com/e6a70c92-4128-439f-8012-382fe78d6396/_packaging/AzureFunctionsTempStaging/nuget/v3/index.json" />
</packageSources>
</configuration>
brettsam marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 9 additions & 0 deletions test/E2ETests/E2EApps/E2EAspNetCoreApp/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;

var host = new HostBuilder()
.ConfigureFunctionsWebApplication()
.Build();

host.Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"profiles": {
"dni_net8_cancellation": {
"commandName": "Project",
"commandLineArgs": "--port 7097",
"launchBrowser": false
}
}
}
12 changes: 12 additions & 0 deletions test/E2ETests/E2EApps/E2EAspNetCoreApp/host.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
},
"enableLiveMetricsFilters": true
}
}
}
7 changes: 7 additions & 0 deletions test/E2ETests/E2EApps/E2EAspNetCoreApp/local.settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
}
}
68 changes: 68 additions & 0 deletions test/E2ETests/E2ETests/AspNetCore/CancellationEndToEndTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Azure.Functions.Tests;
using Microsoft.Azure.Functions.Tests.E2ETests;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.Azure.Functions.Worker.E2ETests.AspNetCore
{
public class CancellationEndToEndTests : IClassFixture<CancellationEndToEndTests.TestFixture>
{
private readonly TestFixture _fixture;

public CancellationEndToEndTests(TestFixture fixture, ITestOutputHelper testOutputHelper)
{
_fixture = fixture;
_fixture.TestLogs.UseTestLogger(testOutputHelper);
}

[IgnoreOnNetFxTestRunTheory]
[InlineData("HttpWithCancellationTokenNotUsed", "Work completed.", "Succeeded")]
[InlineData("HttpWithCancellationTokenIgnored", "TaskCanceledException: A task was canceled", "Failed")]
[InlineData("HttpWithCancellationTokenHandled", "Request was cancelled.", "Succeeded")]
public async Task HttpTriggerFunctions_WithCancellationToken_BehaveAsExpected(string functionName, string expectedMessage, string invocationResult)
liliankasem marked this conversation as resolved.
Show resolved Hide resolved
{
using var cts = new CancellationTokenSource();
var task = HttpHelpers.InvokeHttpTrigger(functionName, cancellationToken: cts.Token);

// Make sure the function invocation started before we cancel
IEnumerable<string> invocationStartLog = null;
await TestUtility.RetryAsync(() =>
{
invocationStartLog = _fixture.TestLogs.CoreToolsLogs.Where(p => p.Contains($"Executing 'Functions.{functionName}'"));
return Task.FromResult(invocationStartLog.Count() >= 1);
});

// The task should be cancelled before it completes, mimicing a client closing the connection.
// This should lead to the worker getting an InvocationCancel request from the functions host
cts.Cancel();
await Assert.ThrowsAsync<TaskCanceledException>(async () => await task);

IEnumerable<string> invocationEndLog = null;
await TestUtility.RetryAsync(() =>
{
invocationEndLog = _fixture.TestLogs.CoreToolsLogs.Where(p => p.Contains($"Executed 'Functions.{functionName}'"));
return Task.FromResult(invocationEndLog.Count() >= 1);
});

Assert.Contains(_fixture.TestLogs.CoreToolsLogs, log => log.Contains(expectedMessage, StringComparison.OrdinalIgnoreCase));

// TODO: 2/3 of the test invocations will fail until the host with the ForwarderProxy fix is released - uncomment this line when the fix is released
// Assert.Contains(_fixture.TestLogs.CoreToolsLogs, log => log.Contains($"'Functions.{functionName}' ({invocationResult}", StringComparison.OrdinalIgnoreCase));
}

public class TestFixture : FunctionAppFixture
{
public TestFixture(IMessageSink messageSink) : base(messageSink, Constants.TestAppNames.E2EAspNetCoreApp)
{
}
}
}
}
6 changes: 6 additions & 0 deletions test/E2ETests/E2ETests/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,11 @@ public static class Tables
public const string TablesConnectionStringSetting = EmulatorConnectionString;
public const string TableName = "TestTable";
}

public static class TestAppNames
{
public const string E2EApp = "E2EApp";
public const string E2EAspNetCoreApp = "E2EAspNetCoreApp";
}
}
}
Loading
Loading