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 topic routing and rate limiting #34

Open
wants to merge 15 commits into
base: master
Choose a base branch
from

Conversation

zzirnheld-mia
Copy link
Contributor

@zzirnheld-mia zzirnheld-mia commented Aug 20, 2024

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:

  • Create multiple MiddleMail server instances with different topics
    • Verify that, as long as the two instances have different subscription IDs and different topics, emails sent to one topic only make it through the corresponding MiddleMail instance.
  • Use a rate limited MiddleMail instance
    • Configure the instance with a specified number of emails per minute
    • Attempt to send more than the limit, and verify that that limit many emails are sent out each minute, until the attempted number are sent.

Checklist

  • Tested with a unit test.
  • Tested locally.
  • Checked for typos.
  • I have made sure that the Instructions for Testers are self-contained. Just by reading the "Instructions for Testers" it is clear what needs to be tested without consulting this Merge Request or the Issue.
  • I have self-reviewed the Description and the Changes of this Merge Request, usually at least one day after making the latest changes here.
  • Has been approved by a developer.
  • Has been approved by a reviewer.

@zzirnheld-mia zzirnheld-mia marked this pull request as ready for review August 22, 2024 14:12
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6</TargetFramework>
<TargetFramework>net7</TargetFramework>
Copy link
Member

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>
Copy link
Member

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>
Copy link
Member

Choose a reason for hiding this comment

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

see above

// 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)));
Copy link
Member

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.

Copy link
Contributor Author

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?

Copy link
Member

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).

Copy link
Contributor Author

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.

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,
Copy link
Member

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.

Copy link
Contributor Author

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

Copy link
Member

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.


// 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.
Copy link
Member

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?

Copy link
Contributor Author

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"?

Copy link
Member

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.

this.processor = processor;
this.logger = logger;
this.consumerTasksPending = 0;
this.messageSource = messageSource;
this.options = options.Value;

if (this.options.RateLimited) {
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

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 :)

Copy link
Contributor Author

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)?

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

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

Copy link
Contributor Author

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)

Copy link
Contributor Author

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.

Copy link
Member

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.

this.processor = processor;
this.logger = logger;
this.consumerTasksPending = 0;
this.messageSource = messageSource;
this.options = options.Value;

if (this.options.RateLimited) {
Copy link
Member

Choose a reason for hiding this comment

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

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,
Copy link
Member

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.


// 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.
Copy link
Member

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.

// 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)));
Copy link
Member

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 {
Copy link
Member

Choose a reason for hiding this comment

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

formatting

Copy link
Contributor Author

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?

Copy link
Member

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 {.

this.processor = processor;
this.logger = logger;
this.consumerTasksPending = 0;
this.messageSource = messageSource;
this.options = options.Value;

if (this.options.RateLimited) {
Copy link
Member

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

@zzirnheld-mia zzirnheld-mia requested a review from leo-labs March 4, 2025 20:58
services.AddOptions<MiddleMailOptions>()
.Bind(hostContext.Configuration.GetSection(MiddleMailOptions.SECTION))
.ValidateDataAnnotations()
.Configure(options =>
Copy link
Member

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?

/// of mails should be sent each minute.
/// </summary>
[Required]
public bool RateLimited { get; set; }
Copy link
Member

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?

Copy link
Contributor Author

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

Copy link
Contributor Author

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

Copy link
Member

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 use RateLimited anywhere

.Bind(hostContext.Configuration.GetSection(MiddleMailOptions.SECTION))
.ValidateDataAnnotations()
.Configure(options =>
options.RateLimiter = new FixedWindowRateLimiter(
Copy link
Member

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?

/// If <see cref="RateLimited"/> behavior is enabled,
/// no more than this many mails will be sent each minute.
/// </summary>
public int LimitPerMinute { get; set; }
Copy link
Member

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..

@@ -1,10 +1,14 @@
#nullable enable
Copy link
Member

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?


public override void Dispose() {
base.Dispose();
rateLimiter?.Dispose();
Copy link
Member

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

.Returns(lease.Object);

async ValueTask<RateLimitLease> simulateAwaitingLease() {
await Task.Delay(10000);
Copy link
Member

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?

Copy link
Member

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? :)

@zzirnheld-mia zzirnheld-mia requested a review from leo-labs March 6, 2025 18:40
Copy link
Member

@leo-labs leo-labs left a 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?

@@ -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);
Copy link
Member

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 {
Copy link
Member

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?!

@zzirnheld-mia zzirnheld-mia requested a review from leo-labs March 6, 2025 19:13
@zzirnheld-mia
Copy link
Contributor Author

zzirnheld-mia commented Mar 6, 2025

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?

Are there changes necessary to deploy without rate limiting or topics? If so, what are they? (I'm not sure what I'm missing)

@leo-labs
Copy link
Member

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);
Copy link
Member

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?

.Returns(lease.Object);

async ValueTask<RateLimitLease> simulateAwaitingLease() {
await Task.Delay(10000);
Copy link
Member

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? :)

@zzirnheld-mia zzirnheld-mia requested a review from leo-labs March 13, 2025 15:48
@@ -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);
Copy link
Member

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

@leo-labs
Copy link
Member

@zzirnheld-mia This looks good to me 🚀
What's left to do is:

  • Add the new config options to the readme
  • Bump package, and chart versions. (Maybe wait for Provide cc support #38 to be merged first)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants