-
Notifications
You must be signed in to change notification settings - Fork 5
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 topic routing and rate limiting #34
base: master
Are you sure you want to change the base?
Conversation
@@ -1,7 +1,7 @@ | |||
<Project Sdk="Microsoft.NET.Sdk"> | |||
|
|||
<PropertyGroup> | |||
<TargetFramework>net6</TargetFramework> | |||
<TargetFramework>net7</TargetFramework> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe net7 is out of support since May this year. If we upgrade we should directly go to net8.
@@ -1,7 +1,7 @@ | |||
<Project Sdk="Microsoft.NET.Sdk"> | |||
|
|||
<PropertyGroup> | |||
<TargetFramework>net6</TargetFramework> | |||
<TargetFramework>net7</TargetFramework> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here
@@ -2,7 +2,7 @@ | |||
|
|||
<PropertyGroup> | |||
<OutputType>Exe</OutputType> | |||
<TargetFramework>net6</TargetFramework> | |||
<TargetFramework>net7</TargetFramework> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
see above
tests/MiddleMailServiceTests.cs
Outdated
// and change the number of verified sent emails to 4. | ||
// DateTime before = DateTime.Now; | ||
var notSentMessage = FakerFactory.EmailMessageFaker.Generate(); | ||
await Task.WhenAny(rateLimitedCallback(notSentMessage), Task.Delay(TimeSpan.FromSeconds(5))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that this test could be more written in a more readable way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What part of how it's written confuses you? The concept of the test is as follows:
3 times:
- send a message
- verify that the message is delivered
one more time:
- initiate sending a message
- wait less time than the rate limit window
- verify that the message is not delivered
In other words, where should I add a comment?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe it would be already good if you move the commented out things that test a separate thing into a different method and separate the tests into Arrange / Act / Assert
sections.
Anyway, I don't think you really need to test that the ratelimiter works, but just that it is called correctly and that you behave correctly depending on the lease (wait or process).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at this again now, I still think this test is helpful to check that the behavior works as intended, even if it doesn't fit into the canonical Arrange / Act / Assert sections. I've separated the test into two tests, one of which could be commented out to speed up manual retesting if necessary.
tests/MiddleMailServiceTests.cs
Outdated
rateLimitedProcessorMock.Verify(m => m.ProcessAsync(It.IsAny<EmailMessage>()), Times.Exactly(i + 1)); | ||
} | ||
|
||
// If you want to test that it takes the correct amount of time to delay, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
one test method should only represent one test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this method represents testing that, if you send 4 messages with a rate limit of 3, it allows exactly 3 messages through, not 4
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
my comment here was about the commented out test code:
If you want to test that it takes the correct amount of time to delay
That would be a different test.
tests/MiddleMailServiceTests.cs
Outdated
|
||
// If you want to test that it takes the correct amount of time to delay, | ||
// uncomment each commented line, | ||
// and change the number of verified sent emails to 4. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we not just make this a configuration option, so we can actually have a working automated test?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can do that. What do you mean by "configuration option"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can make this part of the options, so you can inject it and thus control it during tests.
src/MiddleMail/MiddleMailService.cs
Outdated
this.processor = processor; | ||
this.logger = logger; | ||
this.consumerTasksPending = 0; | ||
this.messageSource = messageSource; | ||
this.options = options.Value; | ||
|
||
if (this.options.RateLimited) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a better integration with DI you can have a look at https://github.com/dotnet/aspnetcore/blob/main/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs and https://github.com/dotnet/aspnetcore/blob/main/src/Middleware/RateLimiting/src/RateLimiterOptions.cs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you do it like that, you are probably able to improve the tests significantly, because you can mock the rate limiter :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I took a look at the examples you linked, but I can't understand how GlobalLimiter
gets assigned in RateLimiterOptions
. However it's assigned in those examples, we need to have the value be populated from environment variables for this program. Do you know how to do that with something like an object (as opposed to a string)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What they do is to use the Options
system to inject and construct a rate limiter. You can do the same here. E.g. like this:
services.AddOptions<RateLimiterOptions>()
.Configure(options =>
options.RateLimiter = new FixedWindowRateLimiter(
new FixedWindowRateLimiterOptions() {
PermitLimit = options.LimitPerMinute,
Window = TimeSpan.FromMinutes(1),
})
);
public class RateLimiterOptions {
public RateLimiter RateLimiter { get; set;}
}
This also allows you to mock your RateLimiter
when testing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the correct way to access options like MiddleMailOptions
in that have just been configured? (To get the LimitPerMinute
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Never mind, I think I understand, the LimitPerMinute is accessed from the same options the RateLimiter
is defined on.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My snippet might not be perfect. I probably would not mix options that are (mis?-)used as a wrapper for DI and for configuration binding, since configuration can actually trigger a recreation of the option object.
There are multiple ways of solving this: You could have an option type for your configuration (so it is typed) and then use a different Configure
overload when configuring RateLimiterOptions
as above, the one where you can inject other services and inject the other option. Another way is to not use an option for the ratelimiter but just a singleton RateLimitAccessor
service with a public property to access the ratelimiter object.
src/MiddleMail/MiddleMailService.cs
Outdated
this.processor = processor; | ||
this.logger = logger; | ||
this.consumerTasksPending = 0; | ||
this.messageSource = messageSource; | ||
this.options = options.Value; | ||
|
||
if (this.options.RateLimited) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tests/MiddleMailServiceTests.cs
Outdated
rateLimitedProcessorMock.Verify(m => m.ProcessAsync(It.IsAny<EmailMessage>()), Times.Exactly(i + 1)); | ||
} | ||
|
||
// If you want to test that it takes the correct amount of time to delay, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
my comment here was about the commented out test code:
If you want to test that it takes the correct amount of time to delay
That would be a different test.
tests/MiddleMailServiceTests.cs
Outdated
|
||
// If you want to test that it takes the correct amount of time to delay, | ||
// uncomment each commented line, | ||
// and change the number of verified sent emails to 4. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can make this part of the options, so you can inject it and thus control it during tests.
tests/MiddleMailServiceTests.cs
Outdated
// and change the number of verified sent emails to 4. | ||
// DateTime before = DateTime.Now; | ||
var notSentMessage = FakerFactory.EmailMessageFaker.Generate(); | ||
await Task.WhenAny(rateLimitedCallback(notSentMessage), Task.Delay(TimeSpan.FromSeconds(5))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe it would be already good if you move the commented out things that test a separate thing into a different method and separate the tests into Arrange / Act / Assert
sections.
Anyway, I don't think you really need to test that the ratelimiter works, but just that it is called correctly and that you behave correctly depending on the lease (wait or process).
if (topic != null) { | ||
await bus.PublishAsync(emailMessage, topic: topic); | ||
} | ||
else { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
formatting
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about the formatting do you want changed here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We place else {
on the same line as the closing bracket from the if
clause, so } else {
.
src/MiddleMail/MiddleMailService.cs
Outdated
this.processor = processor; | ||
this.logger = logger; | ||
this.consumerTasksPending = 0; | ||
this.messageSource = messageSource; | ||
this.options = options.Value; | ||
|
||
if (this.options.RateLimited) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What they do is to use the Options
system to inject and construct a rate limiter. You can do the same here. E.g. like this:
services.AddOptions<RateLimiterOptions>()
.Configure(options =>
options.RateLimiter = new FixedWindowRateLimiter(
new FixedWindowRateLimiterOptions() {
PermitLimit = options.LimitPerMinute,
Window = TimeSpan.FromMinutes(1),
})
);
public class RateLimiterOptions {
public RateLimiter RateLimiter { get; set;}
}
This also allows you to mock your RateLimiter
when testing
src/MiddleMail.Server/Program.cs
Outdated
services.AddOptions<MiddleMailOptions>() | ||
.Bind(hostContext.Configuration.GetSection(MiddleMailOptions.SECTION)) | ||
.ValidateDataAnnotations() | ||
.Configure(options => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you read #34 (comment) where I mentioned that mixing configuration binding and configure is probably not safe to do?
src/MiddleMail/MiddleMailOptions.cs
Outdated
/// of mails should be sent each minute. | ||
/// </summary> | ||
[Required] | ||
public bool RateLimited { get; set; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you still need this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was under the impression we wanted to maintain backwards-compatibility, so we wanted the option to not be rate-limited. Are you suggesting I should make the LimitPerMinute nullable instead, and treat a null value as "no rate limit"? I don't like that option, since it ascribes a meaning to a null value
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess we're not explicitly setting null, just using the lack of setting the value. It's certainly convenient
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we wanted the option to not be rate-limited
Yes, we do. But at the moment you do not have that (you are always register a ratelimiter) plus you do not useRateLimited
anywhere
src/MiddleMail.Server/Program.cs
Outdated
.Bind(hostContext.Configuration.GetSection(MiddleMailOptions.SECTION)) | ||
.ValidateDataAnnotations() | ||
.Configure(options => | ||
options.RateLimiter = new FixedWindowRateLimiter( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you always want to configure a rate limiter?
src/MiddleMail/MiddleMailOptions.cs
Outdated
/// If <see cref="RateLimited"/> behavior is enabled, | ||
/// no more than this many mails will be sent each minute. | ||
/// </summary> | ||
public int LimitPerMinute { get; set; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe you should make this nullable instead and null meaning no rate limit, feels like the best UX to me..
src/MiddleMail/MiddleMailService.cs
Outdated
@@ -1,10 +1,14 @@ | |||
#nullable enable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed that you have not added that to MiddleMailOptions.cs
. Can you remove it here and set it on the project level?
src/MiddleMail/MiddleMailService.cs
Outdated
|
||
public override void Dispose() { | ||
base.Dispose(); | ||
rateLimiter?.Dispose(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that this is a good pattern (disposing an object that is managed by some other object (that is in our case even managed by DI)).
Microsoft/AspNet is a bit sloppy here (https://github.com/dotnet/aspnetcore/blob/main/src/Middleware/RateLimiting/src/RateLimiterOptions.cs#L26C49-L26C62) and does not dispose their rate limiter. On the other hand one can argue that stuff that has the same lifetime as the application does not necessarily need to be disposed of.
The cleanest solution is probably implementing a RateLimitAccessor : IDisposable
singleton service. See also https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection-guidelines#design-services-for-dependency-injection
tests/MiddleMailServiceTests.cs
Outdated
.Returns(lease.Object); | ||
|
||
async ValueTask<RateLimitLease> simulateAwaitingLease() { | ||
await Task.Delay(10000); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you make this more explicit, that this is longer than delayBeforeCheckingIfEmailSent
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean, why not do (2 * delayBeforeCheckingIfEmailSent).TotalMilliseconds
, if that is what you intended here? :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be able to deploy these changes, you also need to make a few changes to the chart. Are you planning to do those in this MR?
src/MiddleMail/MiddleMailService.cs
Outdated
@@ -13,18 +15,23 @@ namespace MiddleMail { | |||
/// <see cref="IMessageProcess" />. | |||
/// Implements a graceful shutdown by waiting for all processing tasks to finish work if cancellation is requested. | |||
/// </summary> | |||
public class MiddleMailService : BackgroundService { | |||
public sealed class MiddleMailService : BackgroundService { | |||
public static readonly TimeSpan RateLimitWindow = TimeSpan.FromMinutes(1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this really be here?
using Microsoft.Extensions.Options; | ||
|
||
namespace MiddleMail { | ||
public class RateLimiterAccessor : IRateLimiterAccessor { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably implement IDisposable
then?!
Are there changes necessary to deploy without rate limiting or topics? If so, what are they? (I'm not sure what I'm missing) |
I don't think so. I was referring to necessary changes to the chart when you actually want to deploy this with rate limiting and different topics (because right now you cannot set these configuration options). |
|
||
namespace MiddleMail { | ||
public class RateLimiterAccessor : IRateLimiterAccessor, IDisposable { | ||
public static readonly TimeSpan RateLimitWindow = TimeSpan.FromMinutes(1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any reason why this is public?
tests/MiddleMailServiceTests.cs
Outdated
.Returns(lease.Object); | ||
|
||
async ValueTask<RateLimitLease> simulateAwaitingLease() { | ||
await Task.Delay(10000); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean, why not do (2 * delayBeforeCheckingIfEmailSent).TotalMilliseconds
, if that is what you intended here? :)
@@ -4,7 +4,7 @@ | |||
|
|||
namespace MiddleMail { | |||
public class RateLimiterAccessor : IRateLimiterAccessor, IDisposable { | |||
public static readonly TimeSpan RateLimitWindow = TimeSpan.FromMinutes(1); | |||
private static readonly TimeSpan RateLimitWindow = TimeSpan.FromMinutes(1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if you make it private, it should not start with an uppercase letter anymore
821ae71
to
91f3ff1
Compare
… dotnet 6 applications
…ts the full rate limiting behavior
…ed on startup, eliminate unnecessary delay on acquiring lease
…es use of Protected()
…ove RateLimited boolean flag in favor of nullable int for LimitPerMinute
91f3ff1
to
4897fed
Compare
@zzirnheld-mia This looks good to me 🚀
|
Short Summary of the Issue
Currently, the MiddleMail instance can end up clogging the SMTP server by sending too many bulk emails before any transactional emails can get through.
Changes made to Resolve the Issue
Added the ability to set a topic string, so a given MiddleMail instance can be tuned to listen for a specific type of email messages.
Added the ability to set a rate limit per minute, so that if two MiddleMail instances send mails to the same SMTP server, we can limit the number of a given type of email that reaches the SMTP server in a given timespan.
Instructions for Testers
Once this has been merged, please test the following:
Checklist