diff --git a/src/Middleware/ConcurrencyLimiter/src/ConcurrencyLimiterEndpointConventionBuilderExtensions.cs b/src/Middleware/ConcurrencyLimiter/src/ConcurrencyLimiterEndpointConventionBuilderExtensions.cs new file mode 100644 index 000000000000..405bdd8ef0e0 --- /dev/null +++ b/src/Middleware/ConcurrencyLimiter/src/ConcurrencyLimiterEndpointConventionBuilderExtensions.cs @@ -0,0 +1,103 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.ConcurrencyLimiter; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Concurrency limit extension methods for + /// + public static class ConcurrencyLimiterEndpointConventionBuilderExtensions + { + /// + /// Adds the concurrency limit with LIFO stack as queueing strategy to the endpoint(s). + /// + /// + /// The . + /// + /// Maximum number of concurrent requests. Any extras will be queued on the server. + /// This option is highly application dependant, and must be configured by the application. + /// + /// + ///Maximum number of queued requests before the server starts rejecting connections with '503 Service Unavailible'. + /// This option is highly application dependant, and must be configured by the application. + /// + /// + public static TBuilder RequireStackPolicy(this TBuilder builder, + int maxConcurrentRequests, + int requestQueueLimit) + where TBuilder : IEndpointConventionBuilder + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Add(endpoints => + { + endpoints.Metadata.Add(new StackPolicyAttribute(maxConcurrentRequests, requestQueueLimit)); + }); + + return builder; + } + /// + /// Adds the concurrency limit with FIFO queue as queueing strategy to the endpoint(s). + /// + /// + /// The . + /// + /// Maximum number of concurrent requests. Any extras will be queued on the server. + /// This option is highly application dependant, and must be configured by the application. + /// + /// + ///Maximum number of queued requests before the server starts rejecting connections with '503 Service Unavailible'. + /// This option is highly application dependant, and must be configured by the application. + /// + /// + public static TBuilder RequireQueuePolicy(this TBuilder builder, + int maxConcurrentRequests, + int requestQueueLimit) + where TBuilder : IEndpointConventionBuilder + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Add(endpoints => + { + endpoints.Metadata.Add(new QueuePolicyAttribute(maxConcurrentRequests, requestQueueLimit)); + }); + + return builder; + } + /// + /// Suppresses the concurrency limit to the endpoint(s). + /// + /// + /// The . + /// + public static TBuilder SupressQueuePolicy(this TBuilder builder) + where TBuilder : IEndpointConventionBuilder + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Add(endpoints => + { + endpoints.Metadata.Add(new SuppressQueuePolicyAttribute()); + }); + + return builder; + } + } +} diff --git a/src/Middleware/ConcurrencyLimiter/src/ConcurrencyLimiterMiddleware.cs b/src/Middleware/ConcurrencyLimiter/src/ConcurrencyLimiterMiddleware.cs index 16c612cae9a1..2bdae0b6dfa9 100644 --- a/src/Middleware/ConcurrencyLimiter/src/ConcurrencyLimiterMiddleware.cs +++ b/src/Middleware/ConcurrencyLimiter/src/ConcurrencyLimiterMiddleware.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -46,7 +47,19 @@ public ConcurrencyLimiterMiddleware(RequestDelegate next, ILoggerFactory loggerF /// A that completes when the request leaves. public async Task Invoke(HttpContext context) { - var waitInQueueTask = _queuePolicy.TryEnterAsync(); + var endpoint = context.GetEndpoint(); + + if (endpoint?.Metadata.GetMetadata() != null) + { + await _next(context); + + return; + } + + var queuePolicy = endpoint?.Metadata.GetMetadata() + ?? _queuePolicy; + + var waitInQueueTask = queuePolicy.TryEnterAsync(); // Make sure we only ever call GetResult once on the TryEnterAsync ValueTask b/c it resets. bool result; @@ -72,7 +85,7 @@ public async Task Invoke(HttpContext context) } finally { - _queuePolicy.OnExit(); + queuePolicy.OnExit(); } } else diff --git a/src/Middleware/ConcurrencyLimiter/src/ISuppressQueuePolicyMetadata.cs b/src/Middleware/ConcurrencyLimiter/src/ISuppressQueuePolicyMetadata.cs new file mode 100644 index 000000000000..bfa84b45d385 --- /dev/null +++ b/src/Middleware/ConcurrencyLimiter/src/ISuppressQueuePolicyMetadata.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.ConcurrencyLimiter +{ + public interface ISuppressQueuePolicyMetadata + { + } +} diff --git a/src/Middleware/ConcurrencyLimiter/src/QueuePolicies/QueuePolicyServiceCollectionExtensions.cs b/src/Middleware/ConcurrencyLimiter/src/QueuePolicies/QueuePolicyServiceCollectionExtensions.cs index 09b9aeb48ff5..b9d9c88b38b0 100644 --- a/src/Middleware/ConcurrencyLimiter/src/QueuePolicies/QueuePolicyServiceCollectionExtensions.cs +++ b/src/Middleware/ConcurrencyLimiter/src/QueuePolicies/QueuePolicyServiceCollectionExtensions.cs @@ -12,7 +12,7 @@ namespace Microsoft.Extensions.DependencyInjection public static class QueuePolicyServiceCollectionExtensions { /// - /// Tells to use a FIFO queue as its queueing strategy. + /// Tells to use a FIFO queue as its default queueing strategy. /// /// The to add services to. /// Set the options used by the queue. @@ -26,7 +26,7 @@ public static IServiceCollection AddQueuePolicy(this IServiceCollection services } /// - /// Tells to use a LIFO stack as its queueing strategy. + /// Tells to use a LIFO stack as its default queueing strategy. /// /// The to add services to. /// Set the options used by the queue. diff --git a/src/Middleware/ConcurrencyLimiter/src/QueuePolicyAttribute.cs b/src/Middleware/ConcurrencyLimiter/src/QueuePolicyAttribute.cs new file mode 100644 index 000000000000..38732f35599b --- /dev/null +++ b/src/Middleware/ConcurrencyLimiter/src/QueuePolicyAttribute.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.ConcurrencyLimiter +{ + /// + /// Specifies that the class or method that this attribute applied to requires limit concurrency request with FIFO queue as queueing strategy. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class QueuePolicyAttribute : Attribute, IQueuePolicy + { + private readonly QueuePolicy _queuePolicy; + /// + /// Initializes a new instance of the class. + /// + /// + /// Maximum number of concurrent requests. Any extras will be queued on the server. + /// This option is highly application dependant, and must be configured by the application. + /// + /// + ///Maximum number of queued requests before the server starts rejecting connections with '503 Service Unavailible'. + /// This option is highly application dependant, and must be configured by the application. + /// + public QueuePolicyAttribute(int maxConcurrentRequests, int requestQueueLimit) + { + _queuePolicy = new QueuePolicy(Options.Create(new QueuePolicyOptions() + { + MaxConcurrentRequests = maxConcurrentRequests, + RequestQueueLimit = requestQueueLimit + })); + } + /// + public void OnExit() + => _queuePolicy.OnExit(); + + + /// + public ValueTask TryEnterAsync() + => _queuePolicy.TryEnterAsync(); + } +} diff --git a/src/Middleware/ConcurrencyLimiter/src/StackPolicyAttribute.cs b/src/Middleware/ConcurrencyLimiter/src/StackPolicyAttribute.cs new file mode 100644 index 000000000000..9a3d5f21b9a8 --- /dev/null +++ b/src/Middleware/ConcurrencyLimiter/src/StackPolicyAttribute.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.ConcurrencyLimiter +{ + /// + /// Specifies that the class or method that this attribute applied to requires limit concurrency request with LIFO stack as queueing strategy. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class StackPolicyAttribute : Attribute, IQueuePolicy + { + private readonly StackPolicy _stackPolicy; + /// + /// Initializes a new instance of the class. + /// + /// + /// Maximum number of concurrent requests. Any extras will be queued on the server. + /// This option is highly application dependant, and must be configured by the application. + /// + /// + ///Maximum number of queued requests before the server starts rejecting connections with '503 Service Unavailible'. + /// This option is highly application dependant, and must be configured by the application. + /// + public StackPolicyAttribute(int maxConcurrentRequests, int requestQueueLimit) + { + _stackPolicy = new StackPolicy(Options.Create(new QueuePolicyOptions() + { + MaxConcurrentRequests = maxConcurrentRequests, + RequestQueueLimit = requestQueueLimit + })); + } + + /// + public void OnExit() + => _stackPolicy.OnExit(); + + /// + public ValueTask TryEnterAsync() + => _stackPolicy.TryEnterAsync(); + } +} diff --git a/src/Middleware/ConcurrencyLimiter/src/SuppressQueuePolicyAttribute.cs b/src/Middleware/ConcurrencyLimiter/src/SuppressQueuePolicyAttribute.cs new file mode 100644 index 000000000000..c1b594e7e974 --- /dev/null +++ b/src/Middleware/ConcurrencyLimiter/src/SuppressQueuePolicyAttribute.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.ConcurrencyLimiter +{ + /// + /// Specifies that the class or method that this attribute applied to does not limit concurrency request. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class SuppressQueuePolicyAttribute : Attribute, ISuppressQueuePolicyMetadata + { + } +} diff --git a/src/Middleware/ConcurrencyLimiter/test/ConcurrencyLimiterEndpointConventionBuilderExtensionsTests.cs b/src/Middleware/ConcurrencyLimiter/test/ConcurrencyLimiterEndpointConventionBuilderExtensionsTests.cs new file mode 100644 index 000000000000..e167b1069c24 --- /dev/null +++ b/src/Middleware/ConcurrencyLimiter/test/ConcurrencyLimiterEndpointConventionBuilderExtensionsTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Xunit; + +namespace Microsoft.AspNetCore.ConcurrencyLimiter.Tests +{ + public class ConcurrencyLimiterEndpointConventionBuilderExtensionsTests + { + [Fact] + public void RequireStackPolicy_StackPolicyAttribute() + { + // Arrange + var builder = new TestEndpointConventionBuilder(); + + // Act + builder.RequireStackPolicy(maxConcurrentRequests: 1, requestQueueLimit: 1); + + // Assert + var convention = Assert.Single(builder.Conventions); + var endpoint = new RouteEndpointBuilder(context => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0); + convention(endpoint); + + Assert.IsAssignableFrom(Assert.Single(endpoint.Metadata)); + } + [Fact] + public void RequireQueuePolicy_QueuePolicyAttribute() + { + // Arrange + var builder = new TestEndpointConventionBuilder(); + + // Act + builder.RequireQueuePolicy(maxConcurrentRequests: 1, requestQueueLimit: 1); + + // Assert + var convention = Assert.Single(builder.Conventions); + var endpoint = new RouteEndpointBuilder(context => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0); + convention(endpoint); + + Assert.IsAssignableFrom(Assert.Single(endpoint.Metadata)); + } + [Fact] + public void SupressQueuePolicy_ISuppressQueuePolicyMetadata() + { + // Arrange + var builder = new TestEndpointConventionBuilder(); + + // Act + builder.SupressQueuePolicy(); + + // Assert + var convention = Assert.Single(builder.Conventions); + var endpoint = new RouteEndpointBuilder(context => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0); + convention(endpoint); + + Assert.IsAssignableFrom(Assert.Single(endpoint.Metadata)); + } + private class TestEndpointConventionBuilder : IEndpointConventionBuilder + { + public IList> Conventions { get; } = new List>(); + + public void Add(Action convention) + { + Conventions.Add(convention); + } + } + } +} diff --git a/src/Middleware/ConcurrencyLimiter/test/Microsoft.AspNetCore.ConcurrencyLimiter.Tests.csproj b/src/Middleware/ConcurrencyLimiter/test/Microsoft.AspNetCore.ConcurrencyLimiter.Tests.csproj index 5bb19e39bebf..48f2d6c52a1b 100644 --- a/src/Middleware/ConcurrencyLimiter/test/Microsoft.AspNetCore.ConcurrencyLimiter.Tests.csproj +++ b/src/Middleware/ConcurrencyLimiter/test/Microsoft.AspNetCore.ConcurrencyLimiter.Tests.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) @@ -13,5 +13,6 @@ + diff --git a/src/Middleware/ConcurrencyLimiter/test/MiddlewareTests.cs b/src/Middleware/ConcurrencyLimiter/test/MiddlewareTests.cs index 79e04036db36..3d24ab342a3b 100644 --- a/src/Middleware/ConcurrencyLimiter/test/MiddlewareTests.cs +++ b/src/Middleware/ConcurrencyLimiter/test/MiddlewareTests.cs @@ -10,6 +10,47 @@ namespace Microsoft.AspNetCore.ConcurrencyLimiter.Tests { public class MiddlewareTests { + [Fact] + public async Task RequestCallNextIfSuppressLimiter() + { + var flag = false; + + var middleware = TestUtils.CreateTestMiddleware( + queue: TestQueue.AlwaysFalse, + next: httpContext => + { + flag = true; + return Task.CompletedTask; + }); + + var httpContext = new DefaultHttpContext(); + httpContext.SetEndpoint(CreateEndpoint(new SuppressQueuePolicyAttribute())); + + await middleware.Invoke(httpContext); + + Assert.True(flag); + } + [Fact] + public async Task RequestCallNextIfMetadataQueuePolicyReturnsTrue() + { + var flag = false; + + var middleware = TestUtils.CreateTestMiddleware( + queue: TestQueue.AlwaysFalse, + next: httpContext => + { + flag = true; + return Task.CompletedTask; + }); + + var httpContext = new DefaultHttpContext(); + httpContext.SetEndpoint(CreateEndpoint(TestQueue.AlwaysTrue)); + + await middleware.Invoke(httpContext); + + Assert.True(flag); + } + [Fact] public async Task RequestsCallNextIfQueueReturnsTrue() { @@ -199,6 +240,11 @@ public async Task MiddlewareOnlyCallsGetResultOnce() Assert.True(flag); } + private Endpoint CreateEndpoint(params object[] metadata) + { + return new Endpoint(context => Task.CompletedTask, new EndpointMetadataCollection(metadata), "Test endpoint"); + } + private class TestQueueForResettableBoolean : IQueuePolicy { public ResettableBooleanCompletionSource Source; diff --git a/src/Middleware/ConcurrencyLimiter/test/QueuePolicyAttributeTests.cs b/src/Middleware/ConcurrencyLimiter/test/QueuePolicyAttributeTests.cs new file mode 100644 index 000000000000..a884f1dafcdb --- /dev/null +++ b/src/Middleware/ConcurrencyLimiter/test/QueuePolicyAttributeTests.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.ConcurrencyLimiter.Tests +{ + public class QueuePolicyAttributeTests + { + [Fact] + public void DoesNotWaitIfSpaceAvailible() + { + var s = TestUtils.CreateQueuePolicyAttribute(2); + + var t1 = s.TryEnterAsync(); + Assert.True(t1.IsCompleted); + + var t2 = s.TryEnterAsync(); + Assert.True(t2.IsCompleted); + + var t3 = s.TryEnterAsync(); + Assert.False(t3.IsCompleted); + } + + [Fact] + public async Task WaitsIfNoSpaceAvailible() + { + var s = TestUtils.CreateQueuePolicyAttribute(1); + Assert.True(await s.TryEnterAsync().OrTimeout()); + + var waitingTask = s.TryEnterAsync(); + Assert.False(waitingTask.IsCompleted); + + s.OnExit(); + Assert.True(await waitingTask.OrTimeout()); + } + + [Fact] + public async Task IsEncapsulated() + { + var s1 = TestUtils.CreateQueuePolicyAttribute(1); + var s2 = TestUtils.CreateQueuePolicy(1); + + Assert.True(await s1.TryEnterAsync().OrTimeout()); + Assert.True(await s2.TryEnterAsync().OrTimeout()); + } + } +} diff --git a/src/Middleware/ConcurrencyLimiter/test/StackPolicyAttributeTests.cs b/src/Middleware/ConcurrencyLimiter/test/StackPolicyAttributeTests.cs new file mode 100644 index 000000000000..2f17fccc5310 --- /dev/null +++ b/src/Middleware/ConcurrencyLimiter/test/StackPolicyAttributeTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.ConcurrencyLimiter.Tests +{ + public class StackPolicyAttributeTests + { + + [Fact] + public static void BaseFunctionality() + { + var stack = TestUtils.CreateStackPolicyAttribute(0, 2); + + var task1 = stack.TryEnterAsync(); + + Assert.False(task1.IsCompleted); + + stack.OnExit(); + + Assert.True(task1.IsCompleted && task1.Result); + } + + [Fact] + public static void OldestRequestOverwritten() + { + var stack = TestUtils.CreateStackPolicyAttribute(0, 3); + + var task1 = stack.TryEnterAsync(); + Assert.False(task1.IsCompleted); + var task2 = stack.TryEnterAsync(); + Assert.False(task2.IsCompleted); + var task3 = stack.TryEnterAsync(); + Assert.False(task3.IsCompleted); + + var task4 = stack.TryEnterAsync(); + + Assert.True(task1.IsCompleted); + Assert.False(task1.Result); + + Assert.False(task2.IsCompleted); + Assert.False(task3.IsCompleted); + Assert.False(task4.IsCompleted); + } + + [Fact] + public static void RespectsMaxConcurrency() + { + var stack = TestUtils.CreateStackPolicyAttribute(2, 2); + + var task1 = stack.TryEnterAsync(); + Assert.True(task1.IsCompleted); + + var task2 = stack.TryEnterAsync(); + Assert.True(task2.IsCompleted); + + var task3 = stack.TryEnterAsync(); + Assert.False(task3.IsCompleted); + } + + [Fact] + public static void ExitRequestsPreserveSemaphoreState() + { + var stack = TestUtils.CreateStackPolicyAttribute(1, 2); + var task1 = stack.TryEnterAsync(); + Assert.True(task1.IsCompleted && task1.Result); + + var task2 = stack.TryEnterAsync(); + Assert.False(task2.IsCompleted); + + stack.OnExit(); // t1 exits, should free t2 to return + Assert.True(task2.IsCompleted && task2.Result); + + stack.OnExit(); // t2 exists, there's now a free spot in server + + var task3 = stack.TryEnterAsync(); + Assert.True(task3.IsCompleted && task3.Result); + } + + [Fact] + public static void StaleRequestsAreProperlyOverwritten() + { + var stack = TestUtils.CreateStackPolicyAttribute(0, 4); + + var task1 = stack.TryEnterAsync(); + stack.OnExit(); + Assert.True(task1.IsCompleted); + + var task2 = stack.TryEnterAsync(); + stack.OnExit(); + Assert.True(task2.IsCompleted); + } + + [Fact] + public static async Task OneTryEnterAsyncOneOnExit() + { + var stack = TestUtils.CreateStackPolicyAttribute(1, 4); + + Assert.Throws(() => stack.OnExit()); + + await stack.TryEnterAsync(); + + stack.OnExit(); + + Assert.Throws(() => stack.OnExit()); + } + } +} diff --git a/src/Middleware/ConcurrencyLimiter/test/TestUtils.cs b/src/Middleware/ConcurrencyLimiter/test/TestUtils.cs index 4ad25c5c5710..909d211b4a2b 100644 --- a/src/Middleware/ConcurrencyLimiter/test/TestUtils.cs +++ b/src/Middleware/ConcurrencyLimiter/test/TestUtils.cs @@ -66,6 +66,16 @@ internal static QueuePolicy CreateQueuePolicy(int maxConcurrentRequests, int req return new QueuePolicy(options); } + + internal static QueuePolicyAttribute CreateQueuePolicyAttribute(int maxConcurrentRequests, int requestQueueLimit = 100) + { + return new QueuePolicyAttribute(maxConcurrentRequests, requestQueueLimit); + } + + internal static StackPolicyAttribute CreateStackPolicyAttribute(int maxConcurrentRequests, int requestQueueLimit = 100) + { + return new StackPolicyAttribute(maxConcurrentRequests, requestQueueLimit); + } } internal class TestQueue : IQueuePolicy