Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RateLimiting APIs #61788

Merged
merged 9 commits into from
Dec 3, 2021
Merged

Conversation

BrennanConroy
Copy link
Member

Part of dotnet/aspnetcore#37385
Moves the current non-generic APIs and two rate limiter implementations from https://github.com/aspnet/AspLabs/tree/main/src/RateLimiting.

There were a couple minor code fixups needed for building in the repo because the project now targets more TFMs than just netstandard2.0. I will call out the changes in code review comments.

Couple things to note:

  • Move Deque to the Common/ folder as it now lives in Channels and RateLimiting
    • I can do this if we want but was trying to limit the scope of this PR
  • Copied the TaskExtensions.DefaultTimeout() methods from asplabs/aspnetcore that we use in our tests to detect hangs/prevent hangs on the CI. Not sure how the Runtime handles test hangs as I haven't seen tests do anything special with awaiting tasks.
  • Does the project need <IsPackable> or any other metadata?

@dotnet-issue-labeler
Copy link

Note regarding the new-api-needs-documentation label:

This serves as a reminder for when your PR is modifying a ref *.cs file and adding/modifying public APIs, to please make sure the API implementation in the src *.cs file is documented with triple slash comments, so the PR reviewers can sign off that change.

@ghost
Copy link

ghost commented Nov 18, 2021

Tagging subscribers to this area: @mangod9
See info in area-owners.md if you want to be subscribed.

Issue Details

Part of dotnet/aspnetcore#37385
Moves the current non-generic APIs and two rate limiter implementations from https://github.com/aspnet/AspLabs/tree/main/src/RateLimiting.

There were a couple minor code fixups needed for building in the repo because the project now targets more TFMs than just netstandard2.0. I will call out the changes in code review comments.

Couple things to note:

  • Move Deque to the Common/ folder as it now lives in Channels and RateLimiting
    • I can do this if we want but was trying to limit the scope of this PR
  • Copied the TaskExtensions.DefaultTimeout() methods from asplabs/aspnetcore that we use in our tests to detect hangs/prevent hangs on the CI. Not sure how the Runtime handles test hangs as I haven't seen tests do anything special with awaiting tasks.
  • Does the project need <IsPackable> or any other metadata?
Author: BrennanConroy
Assignees: -
Labels:

area-System.Threading, new-api-needs-documentation

Milestone: -

}

TaskCompletionSource<RateLimitLease> tcs = new TaskCompletionSource<RateLimitLease>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationTokenRegistration ctr = default;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added = default; since the compiler doesn't like you using non-initialized variables.

{
ctr = cancellationToken.Register(obj =>
{
((TaskCompletionSource<RateLimitLease>)obj!).TrySetException(new OperationCanceledException(cancellationToken));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added ! to obj, to fix nullable warning

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of cancellationToken in the delegate is causing this method to always allocate a closure, even on the fast paths.

}

/// <inheritdoc/>
public bool Equals(MetadataName<T>? other)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added ? since IEquatable requires it, and added the if (other is null) check


TaskCompletionSource<RateLimitLease> tcs = new TaskCompletionSource<RateLimitLease>(TaskCreationOptions.RunContinuationsAsynchronously);

CancellationTokenRegistration ctr = default;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added = default;

{
ctr = cancellationToken.Register(obj =>
{
((TaskCompletionSource<RateLimitLease>)obj!).TrySetException(new OperationCanceledException(cancellationToken));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added ! to obj

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue here with regards to a closure.

}

// Used in tests that test behavior with specific time intervals
private void ReplenishInternal(uint nowTicks)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to private and use reflection in tests to avoid IVT

var wait = limiter.WaitAsync(1);
Assert.False(wait.IsCompleted);

var replenishInternalMethod = typeof(TokenBucketRateLimiter).GetMethod("ReplenishInternal", Reflection.BindingFlags.NonPublic | Reflection.BindingFlags.Instance)!;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to reflection to call private method instead of IVT

}

// Perf: Check SemaphoreSlim implementation instead of locking
if (_permitCount >= permitCount)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These non-synchronized fast-path checks mean we might return failure even if success is possible. That's ok?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine, it's an implicit race, locking would still race with someone releasing permits at the same time.

}
public enum QueueProcessingOrder
{
OldestFirst = 0,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approved API called these:

ProcessOldest,
ProcessNewest

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approved API code was wrong, the comment above the code states:

We decided to rename the QueueProcessingOrder members from { ProcessOldest, ProcessNewest } to { OldestFirst, NewestFirst }

protected abstract void Dispose(bool disposing);
public virtual System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string, object?>> GetAllMetadata() { throw null; }
public abstract bool TryGetMetadata(string metadataName, out object? metadata);
public bool TryGetMetadata<T>(System.Threading.RateLimiting.MetadataName<T> metadataName, [System.Diagnostics.CodeAnalysis.MaybeNullAttribute] out T metadata) { throw null; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't metadata be attributed with [MaybeNullWhen(false)]?

What does it mean to return true from TryGetMetadata, but the metadata still being null?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During one of the reviews it was discussed that a null value could be valid metadata

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember discussing wanting to distinguish between metadata that is sometimes defined but not present (returns true but gives null) vs metadata that is never present (returns false).

Previous discussion: #52079 (comment)

public QueueProcessingOrder QueueProcessingOrder { get; }

/// <summary>
/// Maximum cumulative token count of queued acquisition requests.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description is different than ConcurrencyLimiterOptions:

   /// Maximum number of permits that can be queued concurrently.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is done on purpose. The API review called out that TokenBucket should refer to permits as tokens where possible.


namespace System.Threading.RateLimiting
{
public sealed partial class ConcurrencyLimiter : System.Threading.RateLimiting.RateLimiter
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it odd that we have RateLimiter, TokenBucketRateLimiter, but then ConcurrencyLimiter? Why not ConcurrencyRateLimiter?

Copy link
Member

@martincostello martincostello left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've had a rate-limiting prototype sitting around in Polly for a while (App-vNext/Polly#666) so I thought I'd take a look at this - looks nice.

Copy link
Member

@eerhardt eerhardt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

}

// Return SuccessfulLease or FailedLease to indicate limiter state
if (permitCount == 0 && !_disposed)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to throw ObjectDisposedException if this has been disposed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should an ODE throw synchronously or be wrapped in a ValueTask?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the first question is:

  • Do we want to throw ODE or always return "failed" when a limiter is disposed?

Doing a quick check of other System.Threading disposable types, they seem to throw ODE: Barrier, ManualResetEventSlim, ReaderWriterLockSlim

For the next question, I would say it is similar to argument checking - so we would throw inline.

@BrennanConroy BrennanConroy merged commit 0540772 into dotnet:main Dec 3, 2021
@BrennanConroy BrennanConroy deleted the brecon/ratelimit branch December 3, 2021 23:06
@ghost ghost locked as resolved and limited conversation to collaborators Jan 3, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants