Skip to content

Commit 55f79f5

Browse files
authored
[Flex][Scaling] Allow for running only a specified group of functions (#9785)
* Add FunctionGroupListenerDecorator * Dispose unused listener * Improve log statement, var name * Do not dispose inner listener right away. Add tests
1 parent af3687d commit 55f79f5

File tree

5 files changed

+234
-0
lines changed

5 files changed

+234
-0
lines changed

src/WebJobs.Script.WebHost/WebScriptHostBuilderExtension.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
using Microsoft.AspNetCore.Mvc.ApplicationParts;
77
using Microsoft.Azure.WebJobs.Host.Config;
88
using Microsoft.Azure.WebJobs.Host.Executors;
9+
using Microsoft.Azure.WebJobs.Host.Listeners;
910
using Microsoft.Azure.WebJobs.Host.Loggers;
1011
using Microsoft.Azure.WebJobs.Host.Scale;
1112
using Microsoft.Azure.WebJobs.Host.Storage;
1213
using Microsoft.Azure.WebJobs.Host.Timers;
1314
using Microsoft.Azure.WebJobs.Logging;
1415
using Microsoft.Azure.WebJobs.Script.Config;
1516
using Microsoft.Azure.WebJobs.Script.Configuration;
17+
using Microsoft.Azure.WebJobs.Script.Description;
1618
using Microsoft.Azure.WebJobs.Script.Diagnostics;
1719
using Microsoft.Azure.WebJobs.Script.Middleware;
1820
using Microsoft.Azure.WebJobs.Script.Scale;
@@ -132,6 +134,11 @@ public static IHostBuilder AddWebScriptHost(this IHostBuilder builder, IServiceP
132134
services.TryAddEnumerable(ServiceDescriptor.Singleton<IJobHostHttpMiddleware, JobHostEasyAuthMiddleware>());
133135
}
134136

137+
if (environment.IsFlexConsumptionSku())
138+
{
139+
services.TryAddEnumerable(ServiceDescriptor.Singleton<IListenerDecorator, FunctionGroupListenerDecorator>());
140+
}
141+
135142
services.AddSingleton<IScaleMetricsRepository, TableStorageScaleMetricsRepository>();
136143
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConcurrencyThrottleProvider, WorkerChannelThrottleProvider>());
137144

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.Azure.WebJobs.Host.Listeners;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace Microsoft.Azure.WebJobs.Script.Description
11+
{
12+
internal class FunctionGroupListenerDecorator : IListenerDecorator
13+
{
14+
private readonly IFunctionMetadataManager _metadataManager;
15+
private readonly IEnvironment _environment; // TODO: replace options pattern
16+
private readonly ILogger _logger;
17+
18+
public FunctionGroupListenerDecorator(
19+
IFunctionMetadataManager metadataManager,
20+
IEnvironment environment,
21+
ILogger<FunctionGroupListenerDecorator> logger)
22+
{
23+
_metadataManager = metadataManager ?? throw new ArgumentNullException(nameof(metadataManager));
24+
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
25+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
26+
}
27+
28+
public IListener Decorate(ListenerDecoratorContext context)
29+
{
30+
if (context is null)
31+
{
32+
throw new ArgumentNullException(nameof(context));
33+
}
34+
35+
if (!_environment.TryGetFunctionsTargetGroup(out string targetGroup))
36+
{
37+
// If no group configured, short-circuit.
38+
return context.Listener;
39+
}
40+
41+
_logger.LogInformation("Function group target is {targetGroup}", targetGroup);
42+
43+
// The log name matches the internal metadata we track.
44+
string functionName = context.FunctionDefinition.Descriptor.LogName;
45+
if (!_metadataManager.TryGetFunctionMetadata(functionName, out FunctionMetadata functionMetadata))
46+
{
47+
_logger.LogWarning("Unable to find function metadata for function {functionName}", functionName);
48+
return context.Listener;
49+
}
50+
51+
string group = functionMetadata.GetFunctionGroup() ?? functionName;
52+
if (string.Equals(targetGroup, group, StringComparison.OrdinalIgnoreCase))
53+
{
54+
_logger.LogDebug("Enabling function {functionName}", functionName);
55+
return context.Listener;
56+
}
57+
58+
// A target function group is configured and this function is not part of it.
59+
// By giving a no-op listener, we will prevent it from triggering without 'disabling' it.
60+
_logger.LogDebug("Function {functionName} is not part of group {functionGroup}. Listener will not be enabled.", functionName, targetGroup);
61+
return new NoOpListener(context.Listener);
62+
}
63+
64+
private class NoOpListener : IListener
65+
{
66+
private readonly IListener _listener;
67+
68+
public NoOpListener(IListener listener)
69+
{
70+
// Only hold onto this listener for disposal.
71+
_listener = listener;
72+
}
73+
74+
public void Cancel()
75+
{
76+
}
77+
78+
public void Dispose()
79+
{
80+
_listener.Dispose();
81+
}
82+
83+
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
84+
85+
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
86+
}
87+
}
88+
}

src/WebJobs.Script/Environment/EnvironmentExtensions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,5 +650,23 @@ public static bool IsTargetBasedScalingEnabled(this IEnvironment environment)
650650
{
651651
return !string.Equals(environment.GetEnvironmentVariable(TargetBaseScalingEnabled), "0");
652652
}
653+
654+
/// <summary>
655+
/// Tries to get the target functions group to run, if any specified.
656+
/// </summary>
657+
/// <param name="environment">The environment to use.</param>
658+
/// <param name="group">The target group, if any.</param>
659+
/// <returns>True if group specified, false otherwise.</returns>
660+
public static bool TryGetFunctionsTargetGroup(this IEnvironment environment, out string group)
661+
{
662+
group = environment.GetEnvironmentVariable(FunctionsTargetGroup);
663+
if (group is "")
664+
{
665+
// standardize empty string to null
666+
group = null;
667+
}
668+
669+
return group is not null;
670+
}
653671
}
654672
}

src/WebJobs.Script/Environment/EnvironmentSettingNames.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public static class EnvironmentSettingNames
7676
public const string FunctionsAlwaysReadyInstance = "FUNCTIONS_ALWAYSREADY_INSTANCE";
7777
public const string FunctionsTimeZone = "TZ";
7878
public const string FunctionsWebsiteTimeZone = "WEBSITE_TIME_ZONE";
79+
public const string FunctionsTargetGroup = "FUNCTIONS_TARGET_GROUP";
7980

8081
//Function in Kubernetes
8182
public const string PodNamespace = "POD_NAMESPACE";
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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 Microsoft.Azure.WebJobs.Host.Indexers;
5+
using Microsoft.Azure.WebJobs.Host.Listeners;
6+
using Microsoft.Extensions.Logging;
7+
using Microsoft.Extensions.Logging.Abstractions;
8+
using Moq;
9+
using Xunit;
10+
using FuncDescriptor = Microsoft.Azure.WebJobs.Host.Protocols.FunctionDescriptor;
11+
12+
namespace Microsoft.Azure.WebJobs.Script.Description.Tests
13+
{
14+
public class FunctionGroupListenerDecoratorTests
15+
{
16+
private readonly ILogger<FunctionGroupListenerDecorator> _logger
17+
= NullLogger<FunctionGroupListenerDecorator>.Instance;
18+
19+
[Fact]
20+
public void Decorate_NoTargetGroupConfigured_ReturnsOriginalListener()
21+
{
22+
// Arrange
23+
IFunctionDefinition definition = Mock.Of<IFunctionDefinition>();
24+
IListener original = Mock.Of<IListener>();
25+
IFunctionMetadataManager metadata = Mock.Of<IFunctionMetadataManager>();
26+
IEnvironment environment = CreateEnvironment(null);
27+
28+
var context = new ListenerDecoratorContext(definition, original.GetType(), original);
29+
var decorator = new FunctionGroupListenerDecorator(metadata, environment, _logger);
30+
31+
// Act
32+
var result = decorator.Decorate(context);
33+
34+
// Assert
35+
Assert.Same(context.Listener, result);
36+
}
37+
38+
[Fact]
39+
public void Decorate_MetadataNotFound_ReturnsOriginalListener()
40+
{
41+
// Arrange
42+
IFunctionDefinition definition = CreateDefinition("test");
43+
IListener original = Mock.Of<IListener>();
44+
IFunctionMetadataManager metadata = Mock.Of<IFunctionMetadataManager>();
45+
IEnvironment environment = CreateEnvironment("test-group");
46+
47+
var context = new ListenerDecoratorContext(definition, original.GetType(), original);
48+
var decorator = new FunctionGroupListenerDecorator(metadata, environment, _logger);
49+
50+
// Act
51+
var result = decorator.Decorate(context);
52+
53+
// Assert
54+
Assert.Same(context.Listener, result);
55+
}
56+
57+
[Fact]
58+
public void Decorate_GroupMatch_ReturnsOriginalListener()
59+
{
60+
// Arrange
61+
IFunctionDefinition definition = CreateDefinition("test");
62+
IListener original = Mock.Of<IListener>();
63+
IFunctionMetadataManager metadata = CreateMetadataManager("test", "test-group");
64+
IEnvironment environment = CreateEnvironment("test-group");
65+
66+
var context = new ListenerDecoratorContext(definition, original.GetType(), original);
67+
var decorator = new FunctionGroupListenerDecorator(metadata, environment, _logger);
68+
69+
// Act
70+
var result = decorator.Decorate(context);
71+
72+
// Assert
73+
Assert.Same(context.Listener, result);
74+
}
75+
76+
[Fact]
77+
public void Decorate_GroupDoesNotMatch_ReturnsNoOpListener()
78+
{
79+
// Arrange
80+
IFunctionDefinition definition = CreateDefinition("test");
81+
IListener original = Mock.Of<IListener>();
82+
IFunctionMetadataManager metadata = CreateMetadataManager("test", "test-group");
83+
IEnvironment environment = CreateEnvironment("other-group");
84+
85+
var context = new ListenerDecoratorContext(definition, original.GetType(), original);
86+
var decorator = new FunctionGroupListenerDecorator(metadata, environment, _logger);
87+
88+
// Act
89+
var result = decorator.Decorate(context);
90+
91+
// Assert
92+
Assert.NotSame(context.Listener, result);
93+
}
94+
95+
private static IFunctionDefinition CreateDefinition(string name)
96+
{
97+
var descriptor = new FuncDescriptor { LogName = name };
98+
return Mock.Of<IFunctionDefinition>(m => m.Descriptor == descriptor);
99+
}
100+
101+
private static IFunctionMetadataManager CreateMetadataManager(string name, string group)
102+
{
103+
var metadata = new FunctionMetadata()
104+
{
105+
Properties = { ["FunctionGroup"] = group },
106+
};
107+
108+
var mock = new Mock<IFunctionMetadataManager>();
109+
mock.Setup(p => p.TryGetFunctionMetadata(name, out metadata, false)).Returns(true);
110+
return mock.Object;
111+
}
112+
113+
private static IEnvironment CreateEnvironment(string group)
114+
{
115+
var environment = new Mock<IEnvironment>(MockBehavior.Strict);
116+
environment.Setup(p => p.GetEnvironmentVariable(EnvironmentSettingNames.FunctionsTargetGroup)).Returns(group);
117+
return environment.Object;
118+
}
119+
}
120+
}

0 commit comments

Comments
 (0)