Skip to content

Commit eb92e9f

Browse files
authored
Merge branch 'dev' into jviau/abort-channel
2 parents b03c7bf + e5bb938 commit eb92e9f

File tree

10 files changed

+364
-47
lines changed

10 files changed

+364
-47
lines changed

release_notes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@
55
-->
66
- Memory allocation optimizations in `ScriptStartupTypeLocator.GetExtensionsStartupTypesAsync` (#11012)
77
- Fix invocation timeout when incoming request contains "x-ms-invocation-id" header (#10980)
8+
- Warn if .azurefunctions folder does not exist (#10967)
9+
- Memory allocation & CPU optimizations in `GrpcMessageExtensionUtilities.ConvertFromHttpMessageToExpando` (#11054)

src/WebJobs.Script.Grpc/MessageExtensions/GrpcMessageExtensionUtilities.cs

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Dynamic;
7-
using System.Linq;
87
using Microsoft.AspNetCore.Http;
98
using Microsoft.Azure.WebJobs.Script.Grpc.Messages;
109
using Microsoft.Azure.WebJobs.Script.Workers.Rpc;
@@ -13,30 +12,58 @@ namespace Microsoft.Azure.WebJobs.Script.Grpc
1312
{
1413
internal static class GrpcMessageExtensionUtilities
1514
{
16-
public static object ConvertFromHttpMessageToExpando(RpcHttp inputMessage)
15+
private static readonly object BoxedTrue = true;
16+
private static readonly object BoxedFalse = false;
17+
private static readonly IReadOnlyDictionary<string, object> EmptyHeaders = new Dictionary<string, object>();
18+
19+
public static ExpandoObject ConvertFromHttpMessageToExpando(RpcHttp inputMessage)
1720
{
18-
if (inputMessage == null)
21+
if (inputMessage is null)
1922
{
2023
return null;
2124
}
2225

23-
dynamic expando = new ExpandoObject();
24-
expando.method = inputMessage.Method;
25-
expando.query = inputMessage.Query as IDictionary<string, string>;
26-
expando.statusCode = inputMessage.StatusCode;
27-
expando.headers = inputMessage.Headers.ToDictionary(p => p.Key, p => (object)p.Value);
28-
expando.enableContentNegotiation = inputMessage.EnableContentNegotiation;
26+
var expando = new ExpandoObject();
27+
IDictionary<string, object> dict = expando;
28+
29+
dict["method"] = inputMessage.Method;
30+
dict["query"] = inputMessage.Query;
31+
dict["statusCode"] = inputMessage.StatusCode;
32+
dict["enableContentNegotiation"] = inputMessage.EnableContentNegotiation ? BoxedTrue : BoxedFalse;
2933

30-
expando.cookies = new List<Tuple<string, string, CookieOptions>>();
31-
foreach (RpcHttpCookie cookie in inputMessage.Cookies)
34+
if (inputMessage.Headers is { Count: > 0 })
35+
{
36+
var headerDict = new Dictionary<string, object>(inputMessage.Headers.Count);
37+
foreach (var kvp in inputMessage.Headers)
38+
{
39+
headerDict[kvp.Key] = kvp.Value;
40+
}
41+
dict["headers"] = headerDict;
42+
}
43+
else
3244
{
33-
expando.cookies.Add(RpcHttpCookieConverter(cookie));
45+
dict["headers"] = EmptyHeaders;
3446
}
3547

36-
if (inputMessage.Body != null)
48+
if (inputMessage.Cookies is { Count: > 0 })
3749
{
38-
expando.body = inputMessage.Body.ToObject();
50+
var cookiesList = new List<Tuple<string, string, CookieOptions>>(inputMessage.Cookies.Count);
51+
foreach (var cookie in inputMessage.Cookies)
52+
{
53+
cookiesList.Add(RpcHttpCookieConverter(cookie));
54+
}
55+
dict["cookies"] = cookiesList;
3956
}
57+
else
58+
{
59+
dict["cookies"] = Array.Empty<Tuple<string, string, CookieOptions>>();
60+
}
61+
62+
if (inputMessage.Body is not null)
63+
{
64+
dict["body"] = inputMessage.Body.ToObject();
65+
}
66+
4067
return expando;
4168
}
4269

@@ -80,27 +107,17 @@ public static Tuple<string, string, CookieOptions> RpcHttpCookieConverter(RpcHtt
80107

81108
internal static void UpdateWorkerMetadata(this WorkerMetadata workerMetadata, RpcWorkerConfig workerConfig)
82109
{
83-
workerMetadata.RuntimeName = string.IsNullOrEmpty(workerMetadata.RuntimeName)
84-
? workerConfig.Description.Language : workerMetadata.RuntimeName;
85-
workerMetadata.RuntimeVersion = string.IsNullOrEmpty(workerMetadata.RuntimeVersion)
86-
? workerConfig.Description.DefaultRuntimeVersion : workerMetadata.RuntimeVersion;
110+
workerMetadata.RuntimeName ??= workerConfig.Description.Language;
111+
workerMetadata.RuntimeVersion ??= workerConfig.Description.DefaultRuntimeVersion;
87112
}
88113

89-
private static SameSiteMode RpcSameSiteEnumConverter(RpcHttpCookie.Types.SameSite sameSite)
114+
private static SameSiteMode RpcSameSiteEnumConverter(RpcHttpCookie.Types.SameSite sameSite) => sameSite switch
90115
{
91-
switch (sameSite)
92-
{
93-
case RpcHttpCookie.Types.SameSite.Strict:
94-
return SameSiteMode.Strict;
95-
case RpcHttpCookie.Types.SameSite.Lax:
96-
return SameSiteMode.Lax;
97-
case RpcHttpCookie.Types.SameSite.None:
98-
return SameSiteMode.Unspecified;
99-
case RpcHttpCookie.Types.SameSite.ExplicitNone:
100-
return SameSiteMode.None;
101-
default:
102-
return SameSiteMode.Unspecified;
103-
}
104-
}
116+
RpcHttpCookie.Types.SameSite.Strict => SameSiteMode.Strict,
117+
RpcHttpCookie.Types.SameSite.Lax => SameSiteMode.Lax,
118+
RpcHttpCookie.Types.SameSite.None => SameSiteMode.Unspecified,
119+
RpcHttpCookie.Types.SameSite.ExplicitNone => SameSiteMode.None,
120+
_ => SameSiteMode.Unspecified
121+
};
105122
}
106-
}
123+
}

src/WebJobs.Script.WebHost/DependencyInjection/DependencyValidator/DependencyValidator.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.Azure.WebJobs.Script.Diagnostics;
1010
using Microsoft.Azure.WebJobs.Script.Eventing;
1111
using Microsoft.Azure.WebJobs.Script.FileProvisioning;
12+
using Microsoft.Azure.WebJobs.Script.Host;
1213
using Microsoft.Azure.WebJobs.Script.Scale;
1314
using Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics;
1415
using Microsoft.Azure.WebJobs.Script.Workers;
@@ -48,6 +49,7 @@ private static ExpectedDependencyBuilder CreateExpectedDependencies()
4849
.Expect<WorkerConsoleLogService>()
4950
.Expect<FunctionInvocationDispatcherShutdownManager>()
5051
.Expect<WorkerConcurrencyManager>()
52+
.Optional<FunctionAppValidationService>() // Conditionally registered.
5153
.Optional<FuncAppFileProvisioningService>() // Used by powershell.
5254
.Optional<JobHostService>() // Missing when host is offline.
5355
.Optional<FunctionsSyncService>() // Conditionally registered.

src/WebJobs.Script/DependencyInjection/ScriptStartupTypeLocator.cs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ public async Task<IEnumerable<Type>> GetExtensionsStartupTypesAsync()
110110
}
111111
}
112112

113-
bool isDotnetIsolatedApp = IsDotnetIsolatedApp(functionMetadataCollection);
113+
bool isDotnetIsolatedApp = Utility.IsDotnetIsolatedApp(_environment, functionMetadataCollection);
114114
bool isDotnetApp = isPrecompiledFunctionApp || isDotnetIsolatedApp;
115115
var isLogicApp = _environment.IsLogicApp();
116116

@@ -348,12 +348,6 @@ void CollectError(Type extensionType, Version minimumVersion, ExtensionStartupTy
348348
}
349349
}
350350

351-
private bool IsDotnetIsolatedApp(IEnumerable<FunctionMetadata> functions)
352-
{
353-
string workerRuntime = Utility.GetWorkerRuntime(functions, _environment);
354-
return workerRuntime?.Equals(RpcWorkerConstants.DotNetIsolatedLanguageWorkerName, StringComparison.OrdinalIgnoreCase) ?? false;
355-
}
356-
357351
private ExtensionRequirementsInfo GetExtensionRequirementsInfo()
358352
{
359353
ExtensionRequirementsInfo requirementsInfo = _extensionRequirementOptions.Value.Bundles != null || _extensionRequirementOptions.Value.Extensions != null

src/WebJobs.Script/Diagnostics/Extensions/LoggerExtension.cs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -184,14 +184,24 @@ internal static class LoggerExtension
184184
"Referenced bundle {bundleId} of version {bundleVersion} does not meet the required minimum version of {minimumVersion}. Update your extension bundle reference in host.json to reference {minimumVersion2} or later.");
185185

186186
private static readonly Action<ILogger, string, Exception> _hostJsonZipDeploymentIssue =
187-
LoggerMessage.Define<string>(LogLevel.Error,
188-
new EventId(338, nameof(HostJsonZipDeploymentIssue)),
189-
"No functions were found. A valid host.json file wasn't found in the package root. However, one was located at: {hostJsonFilesPath}. This state indicates that your deployment package was created incorrectly. For deployment package requirements, see https://aka.ms/deployment-zip-push.");
187+
LoggerMessage.Define<string>(LogLevel.Error,
188+
new EventId(338, nameof(HostJsonZipDeploymentIssue)),
189+
"No functions were found. A valid host.json file wasn't found in the package root. However, one was located at: {hostJsonFilesPath}. This state indicates that your deployment package was created incorrectly. For deployment package requirements, see https://aka.ms/deployment-zip-push.");
190190

191191
private static readonly Action<ILogger, Exception> _noHostJsonFile =
192-
LoggerMessage.Define(LogLevel.Information,
193-
new EventId(339, nameof(NoHostJsonFile)),
194-
"No functions were found. This can occur before you deploy code to your function app or when the host.json file is missing from the most recent deployment. Make sure that your deployment package includes the host.json file in the root of the package. For deployment package requirements, see https://aka.ms/functions-deployment-technologies.");
192+
LoggerMessage.Define(LogLevel.Information,
193+
new EventId(339, nameof(NoHostJsonFile)),
194+
"No functions were found. This can occur before you deploy code to your function app or when the host.json file is missing from the most recent deployment. Make sure that your deployment package includes the host.json file in the root of the package. For deployment package requirements, see https://aka.ms/functions-deployment-technologies.");
195+
196+
private static readonly Action<ILogger, string, Exception> _missingAzureFunctionsFolder =
197+
LoggerMessage.Define<string>(LogLevel.Warning,
198+
new EventId(340, nameof(MissingAzureFunctionsFolder)),
199+
"Could not find the .azurefunctions folder in the deployed artifacts of a .NET isolated function app. Make sure that your deployment package includes the .azurefunctions folder at the root of the package. For deployment package requirements, see https://aka.ms/functions-deployment-technologies. If this is not intended to be a .NET isolated app, please ensure that the {functionWorkerRuntime} app setting is configured correctly.");
200+
201+
private static readonly Action<ILogger, string, string, Exception> _incorrectAzureFunctionsFolderPath =
202+
LoggerMessage.Define<string, string>(LogLevel.Warning,
203+
new EventId(341, nameof(IncorrectAzureFunctionsFolderPath)),
204+
"Could not find the .azurefunctions folder in the deployed artifacts of a .NET isolated function app. However, it is found to be located at: {path}. Make sure that your deployment package includes the .azurefunctions folder at the root of the package. For deployment package requirements, see https://aka.ms/functions-deployment-technologies. If this is not intended to be a .NET isolated app, please ensure that the {functionWorkerRuntime} app setting is configured correctly.");
195205

196206
private static readonly Action<ILogger, string, Exception> _publishingMetrics =
197207
LoggerMessage.Define<string>(LogLevel.Debug, new EventId(338, nameof(PublishingMetrics)), "{metrics}");
@@ -387,5 +397,15 @@ public static void NoHostJsonFile(this ILogger logger)
387397
{
388398
_noHostJsonFile(logger, null);
389399
}
400+
401+
public static void MissingAzureFunctionsFolder(this ILogger logger)
402+
{
403+
_missingAzureFunctionsFolder(logger, EnvironmentSettingNames.FunctionWorkerRuntime, null);
404+
}
405+
406+
public static void IncorrectAzureFunctionsFolderPath(this ILogger logger, string path)
407+
{
408+
_incorrectAzureFunctionsFolderPath(logger, path, EnvironmentSettingNames.FunctionWorkerRuntime, null);
409+
}
390410
}
391411
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Linq;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.Azure.WebJobs.Script.Diagnostics.Extensions;
11+
using Microsoft.Extensions.Hosting;
12+
using Microsoft.Extensions.Logging;
13+
using Microsoft.Extensions.Options;
14+
15+
namespace Microsoft.Azure.WebJobs.Script.Host
16+
{
17+
/// <summary>
18+
/// A background service responsible for validating function app payload.
19+
/// </summary>
20+
internal sealed class FunctionAppValidationService : BackgroundService
21+
{
22+
private readonly IEnvironment _environment;
23+
private readonly ILogger<FunctionAppValidationService> _logger;
24+
private readonly IOptions<ScriptJobHostOptions> _scriptOptions;
25+
26+
public FunctionAppValidationService(
27+
ILogger<FunctionAppValidationService> logger,
28+
IOptions<ScriptJobHostOptions> scriptOptions,
29+
IEnvironment environment)
30+
{
31+
_scriptOptions = scriptOptions ?? throw new ArgumentNullException(nameof(scriptOptions));
32+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
33+
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
34+
}
35+
36+
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
37+
{
38+
if (!_scriptOptions.Value.IsStandbyConfiguration)
39+
{
40+
// Adding a delay to ensure that this validation does not impact the cold start performance
41+
Utility.ExecuteAfterColdStartDelay(_environment, Validate, cancellationToken);
42+
}
43+
44+
await Task.CompletedTask;
45+
}
46+
47+
private void Validate()
48+
{
49+
try
50+
{
51+
string azureFunctionsDirPath = Path.Combine(_scriptOptions.Value.RootScriptPath, ScriptConstants.AzureFunctionsSystemDirectoryName);
52+
53+
if (_scriptOptions.Value.RootScriptPath is not null &&
54+
!_scriptOptions.Value.IsDefaultHostConfig &&
55+
Utility.IsDotnetIsolatedApp(environment: _environment) &&
56+
!Directory.Exists(azureFunctionsDirPath))
57+
{
58+
// Search for the .azurefunctions directory within nested directories to verify scenarios where it isn't located at the root. This situation occurs when a function app has been improperly zipped.
59+
IEnumerable<string> azureFunctionsDirectories = Directory.GetDirectories(_scriptOptions.Value.RootScriptPath, ScriptConstants.AzureFunctionsSystemDirectoryName, SearchOption.AllDirectories)
60+
.Where(dir => !dir.Equals(azureFunctionsDirPath, StringComparison.OrdinalIgnoreCase));
61+
62+
if (azureFunctionsDirectories.Any())
63+
{
64+
string azureFunctionsDirectoriesPath = string.Join(", ", azureFunctionsDirectories).Replace(_scriptOptions.Value.RootScriptPath, string.Empty);
65+
_logger.IncorrectAzureFunctionsFolderPath(azureFunctionsDirectoriesPath);
66+
}
67+
else
68+
{
69+
_logger.MissingAzureFunctionsFolder();
70+
}
71+
}
72+
}
73+
catch (Exception ex)
74+
{
75+
_logger.LogTrace("Unable to validate deployed function app payload", ex);
76+
}
77+
}
78+
}
79+
}

src/WebJobs.Script/ScriptHostBuilderExtensions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
using Microsoft.Azure.WebJobs.Script.Extensibility;
3232
using Microsoft.Azure.WebJobs.Script.ExtensionBundle;
3333
using Microsoft.Azure.WebJobs.Script.FileProvisioning;
34+
using Microsoft.Azure.WebJobs.Script.Host;
3435
using Microsoft.Azure.WebJobs.Script.Http;
3536
using Microsoft.Azure.WebJobs.Script.ManagedDependencies;
3637
using Microsoft.Azure.WebJobs.Script.Scale;
@@ -182,6 +183,11 @@ public static IHostBuilder AddScriptHostCore(this IHostBuilder builder, ScriptAp
182183

183184
builder.ConfigureServices((context, services) =>
184185
{
186+
if (!SystemEnvironment.Instance.IsPlaceholderModeEnabled())
187+
{
188+
services.AddHostedService<FunctionAppValidationService>();
189+
}
190+
185191
services.AddSingleton<ExternalConfigurationStartupValidator>();
186192
services.AddSingleton<IHostedService>(s =>
187193
{

src/WebJobs.Script/Utility.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,12 @@ internal static bool IsSingleLanguage(IEnumerable<FunctionMetadata> functions, s
671671
return ContainsFunctionWithWorkerRuntime(filteredFunctions, workerRuntime);
672672
}
673673

674+
internal static bool IsDotnetIsolatedApp(IEnvironment environment, IEnumerable<FunctionMetadata> functions = null)
675+
{
676+
string workerRuntime = GetWorkerRuntime(functions, environment);
677+
return workerRuntime?.Equals(RpcWorkerConstants.DotNetIsolatedLanguageWorkerName, StringComparison.OrdinalIgnoreCase) ?? false;
678+
}
679+
674680
internal static string GetWorkerRuntime(IEnumerable<FunctionMetadata> functions, IEnvironment environment = null)
675681
{
676682
if (environment != null)

test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SpecializationE2ETests.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,46 @@ public async Task ResponseCompressionWorksAfterSpecialization(string acceptEncod
873873
Assert.Equal(expectedContentEncodingResponseHeaderValue, value?.First());
874874
}
875875

876+
[Fact]
877+
public async Task Specialization_DotnetIsolatedApp_MissingAzureFunctionsDir_Logs()
878+
{
879+
Guid guid = Guid.NewGuid();
880+
string path = "test-path" + guid.ToString();
881+
882+
if (!Directory.Exists(path))
883+
{
884+
Directory.CreateDirectory(path);
885+
}
886+
887+
string json = "{\r\n \"version\": \"2.0\",\r\n \"isDefaultHostConfig\": false\r\n}";
888+
File.WriteAllText(Path.Combine(path, "host.json"), json);
889+
890+
var builder = InitializeDotNetIsolatedPlaceholderBuilder(path);
891+
892+
using var testServer = new TestServer(builder);
893+
894+
var standbyManager = testServer.Services.GetService<IStandbyManager>();
895+
Assert.NotNull(standbyManager);
896+
897+
_environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsiteContainerReady, "1");
898+
_environment.SetEnvironmentVariable(EnvironmentSettingNames.FunctionWorkerRuntime, "dotnet-isolated");
899+
SystemEnvironment.Instance.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, "0");
900+
_environment.SetEnvironmentVariable(EnvironmentSettingNames.AzureWebsitePlaceholderMode, "0");
901+
902+
await standbyManager.SpecializeHostAsync();
903+
904+
// Assert: Verify that the host has specialized
905+
var scriptHostManager = testServer.Services.GetService<IScriptHostManager>();
906+
Assert.NotNull(scriptHostManager);
907+
Assert.Equal(ScriptHostState.Running, scriptHostManager.State);
908+
909+
await TestHelpers.Await(() =>
910+
{
911+
int completed = _loggerProvider.GetAllLogMessages().Count(p => p.FormattedMessage.Contains("Could not find the .azurefunctions folder in the deployed artifacts of a .NET isolated function app."));
912+
return completed > 0;
913+
});
914+
}
915+
876916
[Fact]
877917
public async Task DotNetIsolated_PlaceholderHit_WithProxies()
878918
{

0 commit comments

Comments
 (0)