Skip to content

Commit 4da9ff5

Browse files
committed
CommandProcessor: implement per-user load factor limits
1 parent 28445f9 commit 4da9ff5

File tree

3 files changed

+126
-14
lines changed

3 files changed

+126
-14
lines changed

TPP.Core/Commands/CommandProcessor.cs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
using System.Linq;
66
using System.Threading.Tasks;
77
using Microsoft.Extensions.Logging;
8+
using NodaTime;
89
using TPP.ArgsParsing;
10+
using TPP.Core.Utils;
11+
using TPP.Model;
912
using TPP.Persistence;
1013

1114
namespace TPP.Core.Commands
@@ -32,16 +35,45 @@ public class CommandProcessor : ICommandProcessor
3235
private readonly ILogger<CommandProcessor> _logger;
3336
private readonly ICommandLogger _commandLogger;
3437
private readonly ArgsParser _argsParser;
38+
private readonly IClock _clock;
39+
3540
private readonly Dictionary<string, Command> _commands = new();
3641

42+
private readonly float _maxLoadFactor;
43+
private readonly Duration _maxLoadFactorTimeframe;
44+
private readonly float _additionalLoadFactorAtHighThreshold;
45+
private Dictionary<User, TtlQueue<float>> _loadsPerUser = new();
46+
47+
/// <summary>
48+
/// Create a new command processor instance
49+
/// </summary>
50+
/// <param name="logger">logger</param>
51+
/// <param name="commandLogger">command logger</param>
52+
/// <param name="argsParser">args parser instance</param>
53+
/// <param name="clock">clock</param>
54+
/// <param name="maxLoadFactor">maximum load factor before commands are silently dropped</param>
55+
/// <param name="maxLoadFactorTimeframe">timeframe for which the load factor is computed</param>
56+
/// <param name="additionalLoadFactorAtHighThreshold">
57+
/// additional load to add to the load factor when a user is at their maximum load capacity.
58+
/// It is linearly interpolated from 0 when there are no messages within the timeframe,
59+
/// up to the supplied number multiplier when at the maximum amount of messages within the timeframe.
60+
/// This is to have the load factor be more effective against continuous spam than sporadic bursts.</param>
3761
public CommandProcessor(
3862
ILogger<CommandProcessor> logger,
3963
ICommandLogger commandLogger,
40-
ArgsParser argsParser)
64+
ArgsParser argsParser,
65+
IClock clock,
66+
float maxLoadFactor = 200f,
67+
Duration? maxLoadFactorTimeframe = null,
68+
float additionalLoadFactorAtHighThreshold = 3f)
4169
{
4270
_logger = logger;
4371
_commandLogger = commandLogger;
4472
_argsParser = argsParser;
73+
_clock = clock;
74+
_maxLoadFactor = maxLoadFactor;
75+
_maxLoadFactorTimeframe = maxLoadFactorTimeframe ?? Duration.FromMinutes(10);
76+
_additionalLoadFactorAtHighThreshold = additionalLoadFactorAtHighThreshold;
4577
}
4678

4779
public void InstallCommand(Command command)
@@ -77,8 +109,35 @@ public void UninstallCommand(params string[] commandOrAlias)
77109
public Command? FindCommand(string commandName) =>
78110
_commands.TryGetValue(commandName.ToLower(), out Command command) ? command : null;
79111

112+
private float CheckAndUpdateLoadFactorForUser(User user)
113+
{
114+
_loadsPerUser = _loadsPerUser
115+
.Where(kvp => kvp.Value.Count > 0)
116+
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
117+
if (!_loadsPerUser.TryGetValue(user, out TtlQueue<float>? loads))
118+
{
119+
loads = new TtlQueue<float>(_maxLoadFactorTimeframe, _clock);
120+
_loadsPerUser[user] = loads;
121+
}
122+
float sum = loads.Sum();
123+
float ratioFilled = Math.Min(1, sum / _maxLoadFactor);
124+
float toAdd = 1 + ratioFilled * _additionalLoadFactorAtHighThreshold;
125+
loads.Enqueue(toAdd);
126+
return sum + toAdd;
127+
}
128+
80129
public async Task<CommandResult?> Process(string commandName, IImmutableList<string> args, Message message)
81130
{
131+
float loadFactor = CheckAndUpdateLoadFactorForUser(message.User);
132+
_logger.LogDebug("new load factor is {LoadFactor}", loadFactor);
133+
if (loadFactor > _maxLoadFactor)
134+
{
135+
_logger.LogDebug(
136+
"command '{Command}' from user {User} ignored because load factor is {LoadFactor} " +
137+
"for timeframe {Duration}, which is above the maximum of {MaxLoadFactor}",
138+
commandName, message.User, loadFactor, _maxLoadFactorTimeframe, _maxLoadFactor);
139+
return new CommandResult();
140+
}
82141
if (!_commands.TryGetValue(commandName.ToLower(), out Command command))
83142
{
84143
_logger.LogDebug("unknown command '{Command}'", commandName);

TPP.Core/Setups.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public static ICommandProcessor SetUpCommandProcessor(
7070
{
7171
ICommandProcessor commandProcessor = new CommandProcessor(
7272
loggerFactory.CreateLogger<CommandProcessor>(),
73-
databases.CommandLogger, argsParser);
73+
databases.CommandLogger, argsParser, SystemClock.Instance);
7474

7575
IEnumerable<Command> commands = new[]
7676
{

tests/TPP.Core.Tests/Commands/CommandProcessorTest.cs

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,20 @@ public class CommandProcessorTest
2020
private readonly ILogger<CommandProcessor> _nullLogger = new NullLogger<CommandProcessor>();
2121
private readonly Mock<ICommandLogger> _commandLoggerMock = new();
2222
private readonly ImmutableList<string> _noArgs = ImmutableList<string>.Empty;
23-
private readonly User _mockUser = new User(
23+
private static User MockUser() => new(
2424
id: Guid.NewGuid().ToString(),
2525
name: "MockUser", twitchDisplayName: "☺MockUser", simpleName: "mockuser", color: null,
2626
firstActiveAt: Instant.FromUnixTimeSeconds(0), lastActiveAt: Instant.FromUnixTimeSeconds(0),
2727
lastMessageAt: null, pokeyen: 0, tokens: 0);
28+
private readonly User _mockUser = MockUser();
2829

29-
private Message MockMessage(string text = "")
30-
=> new Message(_mockUser, text, MessageSource.Chat, string.Empty);
30+
private Message MockMessage(string text = "") => new(_mockUser, text, MessageSource.Chat, string.Empty);
3131

3232
[Test]
3333
public async Task TestUnknownCommand()
3434
{
35-
var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser());
35+
var commandProcessor = new CommandProcessor(
36+
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
3637

3738
CommandResult? result = await commandProcessor.Process("unknown", _noArgs, MockMessage());
3839

@@ -44,7 +45,8 @@ public async Task TestUnknownCommand()
4445
public async Task TestLogSlowCommand()
4546
{
4647
var loggerMock = new Mock<ILogger<CommandProcessor>>();
47-
var commandProcessor = new CommandProcessor(loggerMock.Object, _commandLoggerMock.Object, new ArgsParser());
48+
var commandProcessor = new CommandProcessor(
49+
loggerMock.Object, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
4850
commandProcessor.InstallCommand(new Command("slow", async _ =>
4951
{
5052
await Task.Delay(TimeSpan.FromMilliseconds(1050));
@@ -62,7 +64,8 @@ public async Task TestLogSlowCommand()
6264
public async Task TestCommandThrowsError()
6365
{
6466
var loggerMock = new Mock<ILogger<CommandProcessor>>();
65-
var commandProcessor = new CommandProcessor(loggerMock.Object, _commandLoggerMock.Object, new ArgsParser());
67+
var commandProcessor = new CommandProcessor(
68+
loggerMock.Object, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
6669
commandProcessor.InstallCommand(new Command("broken",
6770
_ => throw new InvalidOperationException("this command is busted!")));
6871

@@ -78,7 +81,8 @@ public async Task TestCommandThrowsError()
7881
[Test]
7982
public async Task TestCaseInsensitive()
8083
{
81-
var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser());
84+
var commandProcessor = new CommandProcessor(
85+
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
8286
commandProcessor.InstallCommand(new Command("MiXeD", CommandUtils.StaticResponse("Hi!")));
8387

8488
foreach (string command in ImmutableList.Create("MiXeD", "mixed", "MIXED"))
@@ -91,7 +95,8 @@ public async Task TestCaseInsensitive()
9195
[Test]
9296
public async Task TestAliases()
9397
{
94-
var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser());
98+
var commandProcessor = new CommandProcessor(
99+
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
95100
commandProcessor.InstallCommand(new Command(
96101
"main", CommandUtils.StaticResponse("Hi!")) { Aliases = new[] { "alias1", "alias2" } });
97102

@@ -105,7 +110,8 @@ public async Task TestAliases()
105110
[Test]
106111
public void InstallConflictName()
107112
{
108-
var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser());
113+
var commandProcessor = new CommandProcessor(
114+
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
109115

110116
commandProcessor.InstallCommand(new Command("a", CommandUtils.StaticResponse("Hi!")));
111117
ArgumentException ex = Assert.Throws<ArgumentException>(() => commandProcessor
@@ -116,7 +122,8 @@ public void InstallConflictName()
116122
[Test]
117123
public void InstallConflictAlias()
118124
{
119-
var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser());
125+
var commandProcessor = new CommandProcessor(
126+
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
120127

121128
commandProcessor.InstallCommand(new Command(
122129
"a", CommandUtils.StaticResponse("Hi!")) { Aliases = new[] { "x" } });
@@ -128,7 +135,8 @@ public void InstallConflictAlias()
128135
[Test]
129136
public void InstallConflictNameVsAlias()
130137
{
131-
var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser());
138+
var commandProcessor = new CommandProcessor(
139+
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
132140

133141
commandProcessor.InstallCommand(new Command(
134142
"a", CommandUtils.StaticResponse("Hi!")) { Aliases = new[] { "b" } });
@@ -140,7 +148,8 @@ public void InstallConflictNameVsAlias()
140148
[Test]
141149
public async Task TestPermissions()
142150
{
143-
var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser());
151+
var commandProcessor = new CommandProcessor(
152+
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
144153
commandProcessor.InstallCommand(
145154
new Command("opsonly", CommandUtils.StaticResponse("you are an operator")).WithCondition(
146155
canExecute: ctx => IsOperator(ctx.Message.User),
@@ -161,5 +170,49 @@ bool IsOperator(User user) =>
161170
"opsonly", _noArgs, new Message(op, "", MessageSource.Chat, ""));
162171
Assert.That(opResult?.Response, Is.EqualTo("you are an operator"));
163172
}
173+
174+
[Test]
175+
public async Task MaxCommandsPerUser()
176+
{
177+
Mock<IClock> clockMock = new();
178+
var commandProcessor = new CommandProcessor(
179+
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), clockMock.Object,
180+
maxLoadFactor: 6, maxLoadFactorTimeframe: Duration.FromSeconds(10),
181+
additionalLoadFactorAtHighThreshold: 6);
182+
183+
commandProcessor.InstallCommand(new Command("foo",
184+
_ => Task.FromResult(new CommandResult {Response = "yes!"})));
185+
186+
clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(0));
187+
CommandResult? resultOk1 = await commandProcessor.Process(
188+
"foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, ""));
189+
190+
// has +1 additional load factor because the load factor is already at 1/6, which * 6 additional load is 1
191+
// result is a total load of 3
192+
clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(5));
193+
CommandResult? resultOk2 = await commandProcessor.Process(
194+
"foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, ""));
195+
196+
// at 50% load already. this gets rejected and adds an additional +3 load (50% of additional 6 load)
197+
// result is a total load of 7
198+
clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(10));
199+
CommandResult? resultNo = await commandProcessor.Process(
200+
"foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, ""));
201+
202+
// make sure this is per-user
203+
CommandResult? resultOkOtherUser = await commandProcessor.Process(
204+
"foo", ImmutableList.Create(""), new Message(MockUser(), "", MessageSource.Chat, ""));
205+
206+
// letting everything so far expire lets the user use commands again
207+
clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(21));
208+
CommandResult? resultOk3 = await commandProcessor.Process(
209+
"foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, ""));
210+
211+
Assert.That(resultOk1?.Response, Is.EqualTo("yes!"));
212+
Assert.That(resultOk2?.Response, Is.EqualTo("yes!"));
213+
Assert.That(resultNo?.Response, Is.Null);
214+
Assert.That(resultOkOtherUser?.Response, Is.EqualTo("yes!"));
215+
Assert.That(resultOk3?.Response, Is.EqualTo("yes!"));
216+
}
164217
}
165218
}

0 commit comments

Comments
 (0)