Dylan/queue limit (#10590)

* Added queue limit, extra requests are 503'd

* Combined counters into an atomic `TotalRequests`

* small safety fix

* Updated log messages; simplified flow
This commit is contained in:
Dylan Dmitri Gray 2019-05-31 14:53:19 -07:00 committed by GitHub
parent 993d943aec
commit bd4b843678
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 161 additions and 108 deletions

View File

@ -20,5 +20,6 @@ namespace Microsoft.AspNetCore.RequestThrottling
{ {
public RequestThrottlingOptions() { } public RequestThrottlingOptions() { }
public int? MaxConcurrentRequests { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } public int? MaxConcurrentRequests { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public int RequestQueueLimit { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
} }
} }

View File

@ -22,6 +22,7 @@ namespace RequestThrottlingSample
services.Configure<RequestThrottlingOptions>(options => services.Configure<RequestThrottlingOptions>(options =>
{ {
options.MaxConcurrentRequests = 2; options.MaxConcurrentRequests = 2;
options.RequestQueueLimit = 0;
}); });
} }
@ -31,7 +32,7 @@ namespace RequestThrottlingSample
app.Run(async context => app.Run(async context =>
{ {
await context.Response.WriteAsync("Hello Request Throttling! <p></p>"); await context.Response.WriteAsync("Hello Request Throttling! If you refresh this page a bunch, it will 503.");
await Task.Delay(1000); await Task.Delay(1000);
}); });
} }

View File

@ -9,56 +9,54 @@ namespace Microsoft.AspNetCore.RequestThrottling.Internal
{ {
internal class RequestQueue : IDisposable internal class RequestQueue : IDisposable
{ {
private SemaphoreSlim _semaphore; private readonly int _maxConcurrentRequests;
private object _waitingRequestsLock = new object(); private readonly int _requestQueueLimit;
public readonly int MaxConcurrentRequests; private readonly SemaphoreSlim _serverSemaphore;
public int WaitingRequests { get; private set; }
public RequestQueue(int maxConcurrentRequests) private object _totalRequestsLock = new object();
public int TotalRequests { get; private set; }
public RequestQueue(int maxConcurrentRequests, int requestQueueLimit)
{ {
MaxConcurrentRequests = maxConcurrentRequests; _maxConcurrentRequests = maxConcurrentRequests;
_semaphore = new SemaphoreSlim(maxConcurrentRequests); _requestQueueLimit = requestQueueLimit;
_serverSemaphore = new SemaphoreSlim(_maxConcurrentRequests);
} }
public async Task EnterQueue() public async Task<bool> TryEnterQueueAsync()
{ {
var waitInQueueTask = _semaphore.WaitAsync(); // a return value of 'false' indicates that the request is rejected
// a return value of 'true' indicates that the request may proceed
// _serverSemaphore.Release is *not* called in this method, it is called externally when requests leave the server
var needsToWaitOnQueue = !waitInQueueTask.IsCompletedSuccessfully; lock (_totalRequestsLock)
if (needsToWaitOnQueue)
{ {
lock (_waitingRequestsLock) if (TotalRequests >= _requestQueueLimit + _maxConcurrentRequests)
{ {
WaitingRequests++; return false;
} }
await waitInQueueTask; TotalRequests++;
lock (_waitingRequestsLock)
{
WaitingRequests--;
}
} }
await _serverSemaphore.WaitAsync();
return true;
} }
public void Release() public void Release()
{ {
_semaphore.Release(); _serverSemaphore.Release();
}
public int Count lock (_totalRequestsLock)
{ {
get => _semaphore.CurrentCount; TotalRequests--;
} }
public int ConcurrentRequests
{
get => MaxConcurrentRequests - _semaphore.CurrentCount;
} }
public void Dispose() public void Dispose()
{ {
_semaphore.Dispose(); _serverSemaphore.Dispose();
} }
} }
} }

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Diagnostics;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RequestThrottling.Internal; using Microsoft.AspNetCore.RequestThrottling.Internal;
@ -16,7 +17,6 @@ namespace Microsoft.AspNetCore.RequestThrottling
public class RequestThrottlingMiddleware public class RequestThrottlingMiddleware
{ {
private readonly RequestQueue _requestQueue; private readonly RequestQueue _requestQueue;
private readonly RequestThrottlingOptions _options;
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly ILogger _logger; private readonly ILogger _logger;
@ -32,11 +32,16 @@ namespace Microsoft.AspNetCore.RequestThrottling
{ {
throw new ArgumentException("The value of 'options.MaxConcurrentRequests' must be specified.", nameof(options)); throw new ArgumentException("The value of 'options.MaxConcurrentRequests' must be specified.", nameof(options));
} }
if (options.Value.RequestQueueLimit < 0)
{
throw new ArgumentException("The value of 'options.RequestQueueLimit' must be a positive integer.", nameof(options));
}
_next = next; _next = next;
_logger = loggerFactory.CreateLogger<RequestThrottlingMiddleware>(); _logger = loggerFactory.CreateLogger<RequestThrottlingMiddleware>();
_options = options.Value; _requestQueue = new RequestQueue(
_requestQueue = new RequestQueue(_options.MaxConcurrentRequests.Value); options.Value.MaxConcurrentRequests.Value,
options.Value.RequestQueueLimit);
} }
/// <summary> /// <summary>
@ -46,17 +51,24 @@ namespace Microsoft.AspNetCore.RequestThrottling
/// <returns>A <see cref="Task"/> that completes when the request leaves.</returns> /// <returns>A <see cref="Task"/> that completes when the request leaves.</returns>
public async Task Invoke(HttpContext context) public async Task Invoke(HttpContext context)
{ {
var waitInQueueTask = _requestQueue.EnterQueue(); var waitInQueueTask = _requestQueue.TryEnterQueueAsync();
if (waitInQueueTask.IsCompletedSuccessfully && !waitInQueueTask.Result)
if (waitInQueueTask.IsCompletedSuccessfully)
{ {
RequestThrottlingLog.RequestRunImmediately(_logger); RequestThrottlingLog.RequestRejectedQueueFull(_logger);
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
return;
}
else if (!waitInQueueTask.IsCompletedSuccessfully)
{
RequestThrottlingLog.RequestEnqueued(_logger, ActiveRequestCount);
var result = await waitInQueueTask;
RequestThrottlingLog.RequestDequeued(_logger, ActiveRequestCount);
Debug.Assert(result);
} }
else else
{ {
RequestThrottlingLog.RequestEnqueued(_logger, WaitingRequests); RequestThrottlingLog.RequestRunImmediately(_logger, ActiveRequestCount);
await waitInQueueTask;
RequestThrottlingLog.RequestDequeued(_logger, WaitingRequests);
} }
try try
@ -70,46 +82,48 @@ namespace Microsoft.AspNetCore.RequestThrottling
} }
/// <summary> /// <summary>
/// The number of live requests that are downstream from this middleware. /// The number of requests currently on the server.
/// Cannot exceeed <see cref="RequestThrottlingOptions.MaxConcurrentRequests"/>. /// Cannot exceeed the sum of <see cref="RequestThrottlingOptions.RequestQueueLimit"> and </see>/><see cref="RequestThrottlingOptions.MaxConcurrentRequests"/>.
/// </summary> /// </summary>
internal int ConcurrentRequests internal int ActiveRequestCount
{ {
get => _requestQueue.ConcurrentRequests; get => _requestQueue.TotalRequests;
} }
/// <summary> // TODO :: update log wording to reflect the changes
/// Number of requests currently enqueued and waiting to be processed.
/// </summary>
internal int WaitingRequests
{
get => _requestQueue.WaitingRequests;
}
private static class RequestThrottlingLog private static class RequestThrottlingLog
{ {
private static readonly Action<ILogger, int, Exception> _requestEnqueued = private static readonly Action<ILogger, int, Exception> _requestEnqueued =
LoggerMessage.Define<int>(LogLevel.Debug, new EventId(1, "RequestEnqueued"), "Concurrent request limit reached, queuing request. Current queue length: {QueuedRequests}."); LoggerMessage.Define<int>(LogLevel.Debug, new EventId(1, "RequestEnqueued"), "MaxConcurrentRequests limit reached, request has been queued. Current active requests: {ActiveRequests}.");
private static readonly Action<ILogger, int, Exception> _requestDequeued = private static readonly Action<ILogger, int, Exception> _requestDequeued =
LoggerMessage.Define<int>(LogLevel.Debug, new EventId(2, "RequestDequeued"), "Request dequeued. Current queue length: {QueuedRequests}."); LoggerMessage.Define<int>(LogLevel.Debug, new EventId(2, "RequestDequeued"), "Request dequeued. Current active requests: {ActiveRequests}.");
private static readonly Action<ILogger, Exception> _requestRunImmediately = private static readonly Action<ILogger, int, Exception> _requestRunImmediately =
LoggerMessage.Define(LogLevel.Debug, new EventId(3, "RequestRunImmediately"), "Concurrent request limit has not been reached, running request immediately."); LoggerMessage.Define<int>(LogLevel.Debug, new EventId(3, "RequestRunImmediately"), "Below MaxConcurrentRequests limit, running request immediately. Current active requests: {ActiveRequests}");
internal static void RequestEnqueued(ILogger logger, int queuedRequests) private static readonly Action<ILogger, Exception> _requestRejectedQueueFull =
LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequestRejectedQueueFull"), "Currently at the 'RequestQueueLimit', rejecting this request with a '503 server not availible' error");
internal static void RequestEnqueued(ILogger logger, int activeRequests)
{ {
_requestEnqueued(logger, queuedRequests, null); _requestEnqueued(logger, activeRequests, null);
} }
internal static void RequestDequeued(ILogger logger, int queuedRequests) internal static void RequestDequeued(ILogger logger, int activeRequests)
{ {
_requestDequeued(logger, queuedRequests, null); _requestDequeued(logger, activeRequests, null);
} }
internal static void RequestRunImmediately(ILogger logger) internal static void RequestRunImmediately(ILogger logger, int activeRequests)
{ {
_requestRunImmediately(logger, null); _requestRunImmediately(logger, activeRequests, null);
}
internal static void RequestRejectedQueueFull(ILogger logger)
{
_requestRejectedQueueFull(logger, null);
} }
} }
} }

View File

@ -15,5 +15,12 @@ namespace Microsoft.AspNetCore.RequestThrottling
/// This is null by default because the correct value is application specific. This option must be configured by the application. /// This is null by default because the correct value is application specific. This option must be configured by the application.
/// </summary> /// </summary>
public int? MaxConcurrentRequests { get; set; } public int? MaxConcurrentRequests { get; set; }
/// <summary>
/// Maximum number of queued requests before the server starts rejecting connections with '503 Service Unavailible'.
/// Setting this value to 0 will disable the queue; all requests will either immediately enter the server or be rejected.
/// Defaults to 5000 queued requests.
/// </summary>
public int RequestQueueLimit { get; set; } = 5000;
} }
} }

View File

@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.RequestThrottling.Tests
[Fact] [Fact]
public async Task RequestsCanEnterIfSpaceAvailible() public async Task RequestsCanEnterIfSpaceAvailible()
{ {
var middleware = TestUtils.CreateTestMiddleWare(maxConcurrentRequests: 1); var middleware = TestUtils.CreateTestMiddleware(maxConcurrentRequests: 1);
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
// a request should go through with no problems // a request should go through with no problems
@ -24,18 +24,18 @@ namespace Microsoft.AspNetCore.RequestThrottling.Tests
[Fact] [Fact]
public async Task SemaphoreStatePreservedIfRequestsError() public async Task SemaphoreStatePreservedIfRequestsError()
{ {
var middleware = TestUtils.CreateTestMiddleWare( var middleware = TestUtils.CreateTestMiddleware(
maxConcurrentRequests: 1, maxConcurrentRequests: 1,
next: httpContext => next: httpContext =>
{ {
throw new DivideByZeroException(); throw new DivideByZeroException();
}); });
Assert.Equal(0, middleware.ConcurrentRequests); Assert.Equal(0, middleware.ActiveRequestCount);
await Assert.ThrowsAsync<DivideByZeroException>(() => middleware.Invoke(new DefaultHttpContext())); await Assert.ThrowsAsync<DivideByZeroException>(() => middleware.Invoke(new DefaultHttpContext()));
Assert.Equal(0, middleware.ConcurrentRequests); Assert.Equal(0, middleware.ActiveRequestCount);
} }
[Fact] [Fact]
@ -44,7 +44,7 @@ namespace Microsoft.AspNetCore.RequestThrottling.Tests
var blocker = new SyncPoint(); var blocker = new SyncPoint();
var firstRequest = true; var firstRequest = true;
var middleware = TestUtils.CreateTestMiddleWare( var middleware = TestUtils.CreateTestMiddleware(
maxConcurrentRequests: 1, maxConcurrentRequests: 1,
next: httpContext => next: httpContext =>
{ {
@ -58,14 +58,12 @@ namespace Microsoft.AspNetCore.RequestThrottling.Tests
// t1 (as the first request) is blocked by the tcs blocker // t1 (as the first request) is blocked by the tcs blocker
var t1 = middleware.Invoke(new DefaultHttpContext()); var t1 = middleware.Invoke(new DefaultHttpContext());
Assert.Equal(1, middleware.ConcurrentRequests); Assert.Equal(1, middleware.ActiveRequestCount);
Assert.Equal(0, middleware.WaitingRequests);
// t2 is blocked from entering the server since t1 already exists there // t2 is blocked from entering the server since t1 already exists there
// note: increasing MaxConcurrentRequests would allow t2 through while t1 is blocked // note: increasing MaxConcurrentRequests would allow t2 through while t1 is blocked
var t2 = middleware.Invoke(new DefaultHttpContext()); var t2 = middleware.Invoke(new DefaultHttpContext());
Assert.Equal(1, middleware.ConcurrentRequests); Assert.Equal(2, middleware.ActiveRequestCount);
Assert.Equal(1, middleware.WaitingRequests);
// unblock the first task, and the second should follow // unblock the first task, and the second should follow
blocker.Continue(); blocker.Continue();
@ -78,9 +76,56 @@ namespace Microsoft.AspNetCore.RequestThrottling.Tests
{ {
var ex = Assert.Throws<ArgumentException>(() => var ex = Assert.Throws<ArgumentException>(() =>
{ {
TestUtils.CreateTestMiddleWare(maxConcurrentRequests: null); TestUtils.CreateTestMiddleware(maxConcurrentRequests: null);
}); });
Assert.Equal("options", ex.ParamName); Assert.Equal("options", ex.ParamName);
} }
[Fact]
public async void RequestsBlockedIfQueueFull()
{
var middleware = TestUtils.CreateTestMiddleware(
maxConcurrentRequests: 0,
requestQueueLimit: 0,
next: httpContext =>
{
// throttle should bounce the request; it should never get here
throw new NotImplementedException();
});
await middleware.Invoke(new DefaultHttpContext());
}
[Fact]
public async void FullQueueResultsIn503Error()
{
var middleware = TestUtils.CreateTestMiddleware(
maxConcurrentRequests: 0,
requestQueueLimit: 0);
var context = new DefaultHttpContext();
await middleware.Invoke(context);
Assert.Equal(503, context.Response.StatusCode);
}
[Fact]
public void MultipleRequestsFillUpQueue()
{
var middleware = TestUtils.CreateTestMiddleware(
maxConcurrentRequests: 0,
requestQueueLimit: 10,
next: httpContext =>
{
return Task.Delay(TimeSpan.FromSeconds(30));
});
Assert.Equal(0, middleware.ActiveRequestCount);
var _ = middleware.Invoke(new DefaultHttpContext());
Assert.Equal(1, middleware.ActiveRequestCount);
_ = middleware.Invoke(new DefaultHttpContext());
Assert.Equal(2, middleware.ActiveRequestCount);
}
} }
} }

View File

@ -12,69 +12,52 @@ namespace Microsoft.AspNetCore.RequestThrottling.Tests
[Fact] [Fact]
public async Task LimitsIncomingRequests() public async Task LimitsIncomingRequests()
{ {
using var s = new RequestQueue(1); using var s = TestUtils.CreateRequestQueue(1);
Assert.Equal(1, s.Count); Assert.Equal(0, s.TotalRequests);
await s.EnterQueue().OrTimeout(); Assert.True(await s.TryEnterQueueAsync().OrTimeout());
Assert.Equal(0, s.Count); Assert.Equal(1, s.TotalRequests);
s.Release(); s.Release();
Assert.Equal(1, s.Count); Assert.Equal(0, s.TotalRequests);
}
[Fact]
public async Task TracksQueueLength()
{
using var s = new RequestQueue(1);
Assert.Equal(0, s.WaitingRequests);
await s.EnterQueue();
Assert.Equal(0, s.WaitingRequests);
var enterQueueTask = s.EnterQueue();
Assert.Equal(1, s.WaitingRequests);
s.Release();
await enterQueueTask;
Assert.Equal(0, s.WaitingRequests);
} }
[Fact] [Fact]
public void DoesNotWaitIfSpaceAvailible() public void DoesNotWaitIfSpaceAvailible()
{ {
using var s = new RequestQueue(2); using var s = TestUtils.CreateRequestQueue(2);
var t1 = s.EnterQueue(); var t1 = s.TryEnterQueueAsync();
Assert.True(t1.IsCompleted); Assert.True(t1.IsCompleted);
var t2 = s.EnterQueue(); var t2 = s.TryEnterQueueAsync();
Assert.True(t2.IsCompleted); Assert.True(t2.IsCompleted);
var t3 = s.EnterQueue(); var t3 = s.TryEnterQueueAsync();
Assert.False(t3.IsCompleted); Assert.False(t3.IsCompleted);
} }
[Fact] [Fact]
public async Task WaitsIfNoSpaceAvailible() public async Task WaitsIfNoSpaceAvailible()
{ {
using var s = new RequestQueue(1); using var s = TestUtils.CreateRequestQueue(1);
await s.EnterQueue().OrTimeout(); Assert.True(await s.TryEnterQueueAsync().OrTimeout());
var waitingTask = s.EnterQueue(); var waitingTask = s.TryEnterQueueAsync();
Assert.False(waitingTask.IsCompleted); Assert.False(waitingTask.IsCompleted);
s.Release(); s.Release();
await waitingTask.OrTimeout(); Assert.True(await waitingTask.OrTimeout());
} }
[Fact] [Fact]
public async Task IsEncapsulated() public async Task IsEncapsulated()
{ {
using var s1 = new RequestQueue(1); using var s1 = TestUtils.CreateRequestQueue(1);
using var s2 = new RequestQueue(1); using var s2 = TestUtils.CreateRequestQueue(1);
await s1.EnterQueue().OrTimeout(); Assert.True(await s1.TryEnterQueueAsync().OrTimeout());
await s2.EnterQueue().OrTimeout(); Assert.True(await s2.TryEnterQueueAsync().OrTimeout());
} }
} }
} }

View File

@ -6,16 +6,18 @@ using Microsoft.AspNetCore.RequestThrottling;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.RequestThrottling.Internal;
namespace Microsoft.AspNetCore.RequestThrottling.Tests namespace Microsoft.AspNetCore.RequestThrottling.Tests
{ {
public static class TestUtils public static class TestUtils
{ {
public static RequestThrottlingMiddleware CreateTestMiddleWare(int? maxConcurrentRequests, RequestDelegate next = null) public static RequestThrottlingMiddleware CreateTestMiddleware(int? maxConcurrentRequests, int requestQueueLimit = 5000, RequestDelegate next = null)
{ {
var options = new RequestThrottlingOptions var options = new RequestThrottlingOptions
{ {
MaxConcurrentRequests = maxConcurrentRequests MaxConcurrentRequests = maxConcurrentRequests,
RequestQueueLimit = requestQueueLimit
}; };
return new RequestThrottlingMiddleware( return new RequestThrottlingMiddleware(
@ -24,5 +26,7 @@ namespace Microsoft.AspNetCore.RequestThrottling.Tests
options: Options.Create(options) options: Options.Create(options)
); );
} }
internal static RequestQueue CreateRequestQueue(int maxConcurrentRequests) => new RequestQueue(maxConcurrentRequests, 5000);
} }
} }