Skip to content

Commit 3040511

Browse files
committed
Add a warmup API for warmup triggers
1 parent 67483fe commit 3040511

File tree

6 files changed

+178
-4
lines changed

6 files changed

+178
-4
lines changed

src/WebJobs.Script.WebHost/Controllers/HostController.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,17 @@ public IActionResult GetAdminToken()
287287
return Ok(requestHeaderToken);
288288
}
289289

290+
[HttpGet]
291+
[HttpPost]
292+
[Route("admin/warmup")]
293+
[Authorize(Policy = PolicyNames.AdminAuthLevelOrInternal)]
294+
[RequiresRunningHost]
295+
public async Task<IActionResult> Warmup([FromServices] IScriptJobHost scriptHost)
296+
{
297+
await scriptHost.TryInvokeWarmupAsync();
298+
return Ok();
299+
}
300+
290301
[AcceptVerbs("GET", "POST", "DELETE")]
291302
[Authorize(AuthenticationSchemes = AuthLevelAuthenticationDefaults.AuthenticationScheme)]
292303
[Route("runtime/webhooks/{name}/{*extra}")]

src/WebJobs.Script/Host/ScriptJobHostExtensions.cs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,70 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Linq;
7+
using System.Threading.Tasks;
78
using Microsoft.Azure.WebJobs.Script.Description;
89

910
namespace Microsoft.Azure.WebJobs.Script
1011
{
1112
public static class ScriptJobHostExtensions
1213
{
14+
private const string WarmupFunctionName = "Warmup";
15+
private const string WarmupTriggerName = "WarmupTrigger";
16+
1317
/// <summary>
1418
/// Lookup a function by name
1519
/// </summary>
1620
/// <param name="name">name of function</param>
1721
/// <returns>function or null if not found</returns>
1822
public static FunctionDescriptor GetFunctionOrNull(this IScriptJobHost scriptJobHost, string name)
1923
{
20-
return scriptJobHost.Functions.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
24+
return scriptJobHost.Functions.FirstOrDefault(f => IsFunctionNameMatch(f.Name, name));
25+
}
26+
27+
private static bool IsFunctionNameMatch(string functionName, string comparison)
28+
{
29+
return string.Equals(functionName, comparison, StringComparison.OrdinalIgnoreCase);
30+
}
31+
32+
/// <summary>
33+
/// Lookup a warmup function
34+
/// </summary>
35+
/// <returns>Warmup function or null if not found</returns>
36+
public static FunctionDescriptor GetWarmupFunctionOrNull(this IScriptJobHost scriptJobHost)
37+
{
38+
return scriptJobHost.Functions.FirstOrDefault(f =>
39+
{
40+
return IsFunctionNameMatch(f.Name, WarmupFunctionName)
41+
&& f.Metadata
42+
.InputBindings
43+
.Any(b => b.IsTrigger && b.Type.Equals(WarmupTriggerName, StringComparison.OrdinalIgnoreCase));
44+
});
45+
}
46+
47+
/// <summary>
48+
/// Try to invoke a warmup function if available
49+
/// </summary>
50+
/// <returns>
51+
/// A task that represents the asynchronous operation.
52+
/// The task results true if a warmup function was invoked, false otherwise.
53+
/// </returns>
54+
public static async Task<bool> TryInvokeWarmupAsync(this IScriptJobHost scriptJobHost)
55+
{
56+
var warmupFunction = scriptJobHost.GetWarmupFunctionOrNull();
57+
if (warmupFunction != null)
58+
{
59+
ParameterDescriptor inputParameter = warmupFunction.Parameters.First(p => p.IsTrigger);
60+
61+
var arguments = new Dictionary<string, object>()
62+
{
63+
{ inputParameter.Name, new WarmupContext() }
64+
};
65+
66+
await scriptJobHost.CallAsync(warmupFunction.Name, arguments);
67+
return true;
68+
}
69+
70+
return false;
2171
}
2272
}
2373
}

src/WebJobs.Script/ScriptHostBuilderExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ public static IHostBuilder AddScriptHostCore(this IHostBuilder builder, ScriptAp
102102
o.SetResponse = HttpBinding.SetResponse;
103103
})
104104
.AddTimers()
105-
.AddManualTrigger();
105+
.AddManualTrigger()
106+
.AddWarmup();
106107

107108
var extensionBundleOptions = GetExtensionBundleOptions(context);
108109
var bundleManager = new ExtensionBundleManager(extensionBundleOptions, SystemEnvironment.Instance, loggerFactory);

src/WebJobs.Script/WebJobs.Script.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
<PackageReference Include="Microsoft.Azure.Functions.NodeJsWorker" Version="1.1.1" />
4343
<PackageReference Include="Microsoft.Azure.Functions.PowerShellWorker" Version="1.0.188" />
4444
<PackageReference Include="Microsoft.Azure.WebJobs" Version="3.0.14-11660" />
45-
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions" Version="3.0.3" />
45+
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions" Version="3.0.4" />
4646
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.Http" Version="3.0.4" />
4747
<PackageReference Include="Microsoft.Azure.WebJobs.Logging.ApplicationInsights" Version="3.0.14-11656" />
4848
<PackageReference Include="Microsoft.Build" Version="15.8.166" />

test/WebJobs.Script.Tests.Integration/ApplicationInsights/ApplicationInsightsEndToEndTestsBase.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ await TestHelpers.Await(() =>
274274
!t.Message.StartsWith("Host Status")
275275
).ToArray();
276276

277-
int expectedCount = 12;
277+
int expectedCount = 13;
278278
Assert.True(traces.Length == expectedCount, $"Expected {expectedCount} messages, but found {traces.Length}. Actual logs:{Environment.NewLine}{string.Join(Environment.NewLine, traces.Select(t => t.Message))}");
279279

280280
int idx = 0;
@@ -287,6 +287,7 @@ await TestHelpers.Await(() =>
287287
ValidateTrace(traces[idx++], "Host lock lease acquired by instance ID", ScriptConstants.LogCategoryHostGeneral);
288288
ValidateTrace(traces[idx++], "Host started (", LogCategories.Startup);
289289
ValidateTrace(traces[idx++], "Initializing Host", LogCategories.Startup);
290+
ValidateTrace(traces[idx++], "Initializing Warmup Extension", LogCategories.CreateTriggerCategory("Warmup"));
290291
ValidateTrace(traces[idx++], "Job host started", LogCategories.Startup);
291292
ValidateTrace(traces[idx++], "Loading functions metadata", LogCategories.Startup);
292293
ValidateTrace(traces[idx++], "Starting Host (HostId=", LogCategories.Startup);

test/WebJobs.Script.Tests/Controllers/Admin/HostControllerTests.cs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See License.txt in the project root for license information.
33

4+
using System.Collections;
5+
using System.Collections.Generic;
6+
using System.Collections.ObjectModel;
47
using System.IO;
58
using System.Net;
9+
using System.Threading;
610
using System.Threading.Tasks;
711
using Microsoft.AspNetCore.Authentication;
812
using Microsoft.AspNetCore.Authorization;
913
using Microsoft.AspNetCore.Mvc;
1014
using Microsoft.Azure.WebJobs.Host.Scale;
15+
using Microsoft.Azure.WebJobs.Script.Description;
1116
using Microsoft.Azure.WebJobs.Script.ExtensionBundle;
1217
using Microsoft.Azure.WebJobs.Script.Scale;
1318
using Microsoft.Azure.WebJobs.Script.WebHost.Controllers;
@@ -166,5 +171,111 @@ public async Task GetScaleStatus_RuntimeScaleModeNotEnabled_ReturnsBadRequest()
166171
Assert.Equal(HttpStatusCode.BadRequest, (HttpStatusCode)result.StatusCode);
167172
Assert.Equal("Runtime scale monitoring is not enabled.", result.Value);
168173
}
174+
175+
[Theory]
176+
[ClassData(typeof(WarmupTestData))]
177+
public async Task TestWarmupEndpoint_Success(FunctionDescriptor[] functions, bool warmupCalled)
178+
{
179+
var triggerParamName = "triggerParam";
180+
var scriptHostMock = new Mock<IScriptJobHost>();
181+
bool functionInvoked = false;
182+
183+
scriptHostMock.Setup(p => p.CallAsync(It.IsAny<string>(), It.IsAny<IDictionary<string, object>>(), CancellationToken.None))
184+
.Callback<string, IDictionary<string, object>, CancellationToken>((name, args, token) =>
185+
{
186+
Assert.Equal("warmup", name);
187+
Assert.Equal(1, args.Count);
188+
Assert.IsType<WarmupContext>(args[triggerParamName]);
189+
190+
functionInvoked = true;
191+
})
192+
.Returns(Task.CompletedTask);
193+
scriptHostMock.SetupGet(p => p.Functions).Returns(functions);
194+
195+
IActionResult response = await _hostController.Warmup(scriptHostMock.Object);
196+
197+
Assert.Equal(warmupCalled, functionInvoked);
198+
Assert.IsType<OkResult>(response);
199+
}
200+
201+
public class WarmupTestData : IEnumerable<object[]>
202+
{
203+
private readonly BindingMetadata _blobInputBinding;
204+
private readonly BindingMetadata _blobOutputBinding;
205+
private readonly BindingMetadata _blobTriggerBinding;
206+
private readonly BindingMetadata _warmupTriggerBinding;
207+
private readonly BindingMetadata _manualTriggerBinding;
208+
209+
private readonly ParameterDescriptor _triggerParam;
210+
private readonly ParameterDescriptor _nonTriggerParam;
211+
212+
private readonly FunctionDescriptor _warmupFunctionErrName;
213+
private readonly FunctionDescriptor _warmupFunctionWarmupName;
214+
private readonly FunctionDescriptor _manualFunctionWarmupName;
215+
private readonly FunctionDescriptor _blobFunctionBlobName;
216+
217+
public WarmupTestData()
218+
{
219+
_triggerParam = new ParameterDescriptor("triggerParam", null)
220+
{
221+
IsTrigger = true
222+
};
223+
224+
_nonTriggerParam = new ParameterDescriptor("nonTriggerParam", null)
225+
{
226+
IsTrigger = false
227+
};
228+
229+
_blobInputBinding = GetBindingMetadata("boringBlob", "blob", BindingDirection.In);
230+
_blobOutputBinding = GetBindingMetadata("bigBlob", "blob", BindingDirection.Out);
231+
_blobTriggerBinding = GetBindingMetadata("beautifulBlob", "blobTrigger", BindingDirection.In);
232+
_warmupTriggerBinding = GetBindingMetadata("superState", "warmupTrigger", BindingDirection.In);
233+
_manualTriggerBinding = GetBindingMetadata("majesticManual", "manualTrigger", BindingDirection.In);
234+
235+
var warmupMetadata = new Script.Description.FunctionMetadata();
236+
warmupMetadata.Bindings.Add(_warmupTriggerBinding);
237+
warmupMetadata.Bindings.Add(_blobInputBinding);
238+
_warmupFunctionWarmupName = new FunctionDescriptor("warmup", null, warmupMetadata,
239+
new Collection<ParameterDescriptor>() { _nonTriggerParam, _triggerParam }, null, null, null);
240+
241+
_warmupFunctionErrName = new FunctionDescriptor("donotwarmup", null, warmupMetadata,
242+
new Collection<ParameterDescriptor>() { _nonTriggerParam, _triggerParam }, null, null, null);
243+
244+
var manualMetadata = new Script.Description.FunctionMetadata();
245+
manualMetadata.Bindings.Add(_manualTriggerBinding);
246+
manualMetadata.Bindings.Add(_blobInputBinding);
247+
_manualFunctionWarmupName = new FunctionDescriptor("warmup", null, manualMetadata,
248+
new Collection<ParameterDescriptor>() { _nonTriggerParam, _triggerParam }, null, null, null);
249+
250+
var blobMetadata = new Script.Description.FunctionMetadata();
251+
blobMetadata.Bindings.Add(_blobTriggerBinding);
252+
blobMetadata.Bindings.Add(_blobOutputBinding);
253+
_blobFunctionBlobName = new FunctionDescriptor("blobFunction", null, blobMetadata,
254+
new Collection<ParameterDescriptor>() { _nonTriggerParam, _triggerParam }, null, null, null);
255+
}
256+
257+
private BindingMetadata GetBindingMetadata(string name, string type, BindingDirection dir)
258+
{
259+
return new BindingMetadata()
260+
{
261+
Name = name,
262+
Type = type,
263+
Direction = dir
264+
};
265+
}
266+
267+
public IEnumerator<object[]> GetEnumerator()
268+
{
269+
return new List<object[]>
270+
{
271+
new object[] { new[] { _warmupFunctionWarmupName, _manualFunctionWarmupName }, true },
272+
new object[] { new[] { _blobFunctionBlobName, _manualFunctionWarmupName }, false },
273+
new object[] { new[] { _warmupFunctionErrName, _manualFunctionWarmupName }, false },
274+
new object[] { new[] { _warmupFunctionWarmupName, _blobFunctionBlobName }, true }
275+
}.GetEnumerator();
276+
}
277+
278+
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
279+
}
169280
}
170281
}

0 commit comments

Comments
 (0)