Skip to content

Commit 89ef908

Browse files
authored
Adding a timeout to IFunctionProvider.GetFunctionMetadataAsync (#10249)
* Adding a timeout to IFunctionProvider.GetFunctionMetadataAsync so that if a provider does not return, it will not cause a deadlock state. * Adding release notes. * Added a new internal property `MetadataProviderTimeoutInSeconds` which can be set to a different value than default, from the tests. * Added comment about property being settable. * PR feedback fixes. Throws an error when the function metadata provider method throws or when the operation timeseout.
1 parent 633e2c0 commit 89ef908

File tree

3 files changed

+106
-7
lines changed

3 files changed

+106
-7
lines changed

release_notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@
1717
- Ordered invocations are now the default (#10201)
1818
- Skip worker description if none of the profile conditions are met (#9932)
1919
- Fixed incorrect function count in the log message.(#10220)
20+
- Adding a timeout when retrieving function metadata from metadata providers (#10219)

src/WebJobs.Script/Host/FunctionMetadataManager.cs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ namespace Microsoft.Azure.WebJobs.Script
2121
{
2222
public class FunctionMetadataManager : IFunctionMetadataManager
2323
{
24-
private const string _functionConfigurationErrorMessage = "Unable to determine the primary function script.Make sure atleast one script file is present.Try renaming your entry point script to 'run' or alternatively you can specify the name of the entry point script explicitly by adding a 'scriptFile' property to your function metadata.";
25-
private const string _metadataProviderName = "Custom";
24+
private const string FunctionConfigurationErrorMessage = "Unable to determine the primary function script.Make sure atleast one script file is present.Try renaming your entry point script to 'run' or alternatively you can specify the name of the entry point script explicitly by adding a 'scriptFile' property to your function metadata.";
25+
private const string MetadataProviderName = "Custom";
26+
private const int DefaultMetadataProviderTimeoutInSeconds = 30;
2627
private readonly IServiceProvider _serviceProvider;
2728
private IFunctionMetadataProvider _functionMetadataProvider;
2829
private bool _isHttpWorker;
@@ -56,6 +57,9 @@ public FunctionMetadataManager(IOptions<ScriptJobHostOptions> scriptOptions, IFu
5657
};
5758
}
5859

60+
// Property is settable for testing purposes.
61+
internal int MetadataProviderTimeoutInSeconds { get; set; } = DefaultMetadataProviderTimeoutInSeconds;
62+
5963
public ImmutableDictionary<string, ImmutableArray<string>> Errors { get; private set; }
6064

6165
public bool TryGetFunctionMetadata(string functionName, out FunctionMetadata functionMetadata, bool forceRefresh)
@@ -79,7 +83,7 @@ public bool TryGetFunctionMetadata(string functionName, out FunctionMetadata fun
7983
/// </summary>
8084
/// <param name="forceRefresh">Forces reload from all providers.</param>
8185
/// <param name="applyAllowList">Apply functions allow list filter.</param>
82-
/// <param name="includeCustomProviders">Include any metadata provided by IFunctionProvider when loading the metadata</param>
86+
/// <param name="includeCustomProviders">Include any metadata provided by IFunctionProvider when loading the metadata.</param>
8387
/// <returns> An Immutable array of FunctionMetadata.</returns>
8488
public ImmutableArray<FunctionMetadata> GetFunctionMetadata(bool forceRefresh, bool applyAllowList = true, bool includeCustomProviders = true, IList<RpcWorkerConfig> workerConfigs = null)
8589
{
@@ -188,7 +192,7 @@ internal bool IsScriptFileDetermined(FunctionMetadata functionMetadata)
188192
{
189193
if (string.IsNullOrEmpty(functionMetadata.ScriptFile) && !_isHttpWorker && !functionMetadata.IsProxy() && _servicesReset)
190194
{
191-
throw new FunctionConfigurationException(_functionConfigurationErrorMessage);
195+
throw new FunctionConfigurationException(FunctionConfigurationErrorMessage);
192196
}
193197
}
194198
catch (FunctionConfigurationException exc)
@@ -214,12 +218,27 @@ private void LoadCustomProviderFunctions(List<FunctionMetadata> functionMetadata
214218

215219
private void AddMetadataFromCustomProviders(IEnumerable<IFunctionProvider> functionProviders, List<FunctionMetadata> functionMetadataList)
216220
{
217-
_logger.ReadingFunctionMetadataFromProvider(_metadataProviderName);
221+
_logger.ReadingFunctionMetadataFromProvider(MetadataProviderName);
218222

219223
var functionProviderTasks = new List<Task<ImmutableArray<FunctionMetadata>>>();
224+
220225
foreach (var functionProvider in functionProviders)
221226
{
222-
functionProviderTasks.Add(functionProvider.GetFunctionMetadataAsync());
227+
var getFunctionMetadataFromProviderTask = functionProvider.GetFunctionMetadataAsync();
228+
var delayTask = Task.Delay(TimeSpan.FromSeconds(MetadataProviderTimeoutInSeconds));
229+
230+
var completedTask = Task.WhenAny(getFunctionMetadataFromProviderTask, delayTask).ContinueWith(t =>
231+
{
232+
if (t.Result == getFunctionMetadataFromProviderTask && getFunctionMetadataFromProviderTask.IsCompletedSuccessfully)
233+
{
234+
return getFunctionMetadataFromProviderTask.Result;
235+
}
236+
237+
// Timeout case.
238+
throw new TimeoutException($"Timeout occurred while retrieving metadata from provider '{functionProvider.GetType().FullName}'. The operation exceeded the configured timeout of {MetadataProviderTimeoutInSeconds} seconds.");
239+
});
240+
241+
functionProviderTasks.Add(completedTask);
223242
}
224243

225244
var providerFunctionMetadataResults = Task.WhenAll(functionProviderTasks).GetAwaiter().GetResult();
@@ -228,7 +247,7 @@ private void AddMetadataFromCustomProviders(IEnumerable<IFunctionProvider> funct
228247
// This is used to make sure no duplicates are registered
229248
var distinctFunctionNames = new HashSet<string>(functionMetadataList.Select(m => m.Name));
230249

231-
_logger.FunctionsReturnedByProvider(totalFunctionsCount, _metadataProviderName);
250+
_logger.FunctionsReturnedByProvider(totalFunctionsCount, MetadataProviderName);
232251

233252
foreach (var metadataArray in providerFunctionMetadataResults)
234253
{

test/WebJobs.Script.Tests/FunctionMetadataManagerTests.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,85 @@ public void FunctionMetadataManager_GetsMetadata_FromMultipleFunctionProviders_S
187187
Assert.Single(traces.Where(t => t.FormattedMessage.Contains($"{expectedTotalFunctionsCount} functions found (Custom)")));
188188
}
189189

190+
[Fact]
191+
public void FunctionMetadataManager_LoadFunctionMetadata_Throws_WhenFunctionProviderThrows()
192+
{
193+
var functionMetadataCollection = new Collection<FunctionMetadata>();
194+
var mockFunctionErrors = new Dictionary<string, ImmutableArray<string>>();
195+
var mockFunctionMetadataProvider = new Mock<IFunctionMetadataProvider>();
196+
var badFunctionMetadataProvider = new Mock<IFunctionProvider>();
197+
var goodFunctionMetadataProvider = new Mock<IFunctionProvider>();
198+
var workerConfigs = TestHelpers.GetTestWorkerConfigs();
199+
var testLoggerProvider = new TestLoggerProvider();
200+
var loggerFactory = new LoggerFactory();
201+
loggerFactory.AddProvider(testLoggerProvider);
202+
203+
mockFunctionMetadataProvider.Setup(m => m.GetFunctionMetadataAsync(workerConfigs, SystemEnvironment.Instance, false)).Returns(Task.FromResult(new Collection<FunctionMetadata>().ToImmutableArray()));
204+
mockFunctionMetadataProvider.Setup(m => m.FunctionErrors).Returns(new Dictionary<string, ICollection<string>>().ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray()));
205+
206+
// A good provider that returns 2 functions
207+
functionMetadataCollection.Add(GetTestFunctionMetadata("somefile.dll", name: "Function1"));
208+
functionMetadataCollection.Add(GetTestFunctionMetadata("somefile.dll", name: "Function2"));
209+
goodFunctionMetadataProvider.Setup(m => m.GetFunctionMetadataAsync()).ReturnsAsync(functionMetadataCollection.ToImmutableArray());
210+
211+
// A bad provider that will throw an exception for .GetFunctionMetadataAsync call.
212+
var tcs = new TaskCompletionSource<ImmutableArray<FunctionMetadata>>();
213+
badFunctionMetadataProvider.Setup(m => m.GetFunctionMetadataAsync()).Throws(new IOException("There was a custom IO error"));
214+
215+
FunctionMetadataManager testFunctionMetadataManager = TestFunctionMetadataManager.GetFunctionMetadataManager(new OptionsWrapper<ScriptJobHostOptions>(_scriptJobHostOptions),
216+
mockFunctionMetadataProvider.Object, new List<IFunctionProvider>() { goodFunctionMetadataProvider.Object, badFunctionMetadataProvider.Object }, new OptionsWrapper<HttpWorkerOptions>(_defaultHttpWorkerOptions), loggerFactory, new TestOptionsMonitor<LanguageWorkerOptions>(TestHelpers.GetTestLanguageWorkerOptions()));
217+
218+
var exception = Assert.Throws<IOException>(() => testFunctionMetadataManager.LoadFunctionMetadata());
219+
Assert.Contains($"There was a custom IO error", exception.Message);
220+
221+
var traces = testLoggerProvider.GetAllLogMessages();
222+
Assert.Single(traces, t => t.FormattedMessage.Contains("Reading functions metadata (Custom)"));
223+
Assert.DoesNotContain(traces, t => t.FormattedMessage.Contains("2 functions found (Custom)"));
224+
}
225+
226+
[Fact]
227+
public void FunctionMetadataManager_LoadFunctionMetadata_Throws_WhenFunctionProvidersTimesOut()
228+
{
229+
var functionMetadataCollection = new Collection<FunctionMetadata>();
230+
var mockFunctionErrors = new Dictionary<string, ImmutableArray<string>>();
231+
var mockFunctionMetadataProvider = new Mock<IFunctionMetadataProvider>();
232+
var badFunctionMetadataProvider = new Mock<IFunctionProvider>();
233+
var goodFunctionMetadataProvider = new Mock<IFunctionProvider>();
234+
var workerConfigs = TestHelpers.GetTestWorkerConfigs();
235+
var testLoggerProvider = new TestLoggerProvider();
236+
var loggerFactory = new LoggerFactory();
237+
loggerFactory.AddProvider(testLoggerProvider);
238+
239+
mockFunctionMetadataProvider.Setup(m => m.GetFunctionMetadataAsync(workerConfigs, SystemEnvironment.Instance, false)).Returns(Task.FromResult(new Collection<FunctionMetadata>().ToImmutableArray()));
240+
mockFunctionMetadataProvider.Setup(m => m.FunctionErrors).Returns(new Dictionary<string, ICollection<string>>().ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray()));
241+
242+
// A good provider that returns 2 functions
243+
functionMetadataCollection.Add(GetTestFunctionMetadata("somefile.dll", name: "Function1"));
244+
functionMetadataCollection.Add(GetTestFunctionMetadata("somefile.dll", name: "Function2"));
245+
goodFunctionMetadataProvider.Setup(m => m.GetFunctionMetadataAsync()).ReturnsAsync(functionMetadataCollection.ToImmutableArray());
246+
247+
// A bad provider that will never return anything.
248+
var tcs = new TaskCompletionSource<ImmutableArray<FunctionMetadata>>();
249+
badFunctionMetadataProvider.Setup(m => m.GetFunctionMetadataAsync()).Returns(async () =>
250+
{
251+
await Task.Delay(TimeSpan.FromSeconds(2)); // Simulate a delay longer than the timeout
252+
return ImmutableArray<FunctionMetadata>.Empty;
253+
});
254+
255+
FunctionMetadataManager testFunctionMetadataManager = TestFunctionMetadataManager.GetFunctionMetadataManager(new OptionsWrapper<ScriptJobHostOptions>(_scriptJobHostOptions),
256+
mockFunctionMetadataProvider.Object, new List<IFunctionProvider>() { goodFunctionMetadataProvider.Object, badFunctionMetadataProvider.Object }, new OptionsWrapper<HttpWorkerOptions>(_defaultHttpWorkerOptions), loggerFactory, new TestOptionsMonitor<LanguageWorkerOptions>(TestHelpers.GetTestLanguageWorkerOptions()));
257+
258+
// Set the timeout to 1 second for the test.
259+
testFunctionMetadataManager.MetadataProviderTimeoutInSeconds = 1;
260+
261+
var exception = Assert.Throws<TimeoutException>(() => testFunctionMetadataManager.LoadFunctionMetadata());
262+
Assert.Contains($"Timeout occurred while retrieving metadata from provider '{badFunctionMetadataProvider.Object.GetType().FullName}'. The operation exceeded the configured timeout of 1 seconds.", exception.Message);
263+
264+
var traces = testLoggerProvider.GetAllLogMessages();
265+
Assert.Single(traces, t => t.FormattedMessage.Contains("Reading functions metadata (Custom)"));
266+
Assert.DoesNotContain(traces, t => t.FormattedMessage.Contains("2 functions found (Custom)"));
267+
}
268+
190269
[Fact]
191270
public void FunctionMetadataManager_GetsMetadata_FromFunctionProviders()
192271
{

0 commit comments

Comments
 (0)