Skip to content

Commit

Permalink
CommandProcessor: implement per-user load factor limits
Browse files Browse the repository at this point in the history
  • Loading branch information
Felk committed Sep 20, 2021
1 parent 28445f9 commit 4da9ff5
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 14 deletions.
61 changes: 60 additions & 1 deletion TPP.Core/Commands/CommandProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NodaTime;
using TPP.ArgsParsing;
using TPP.Core.Utils;
using TPP.Model;
using TPP.Persistence;

namespace TPP.Core.Commands
Expand All @@ -32,16 +35,45 @@ public class CommandProcessor : ICommandProcessor
private readonly ILogger<CommandProcessor> _logger;
private readonly ICommandLogger _commandLogger;
private readonly ArgsParser _argsParser;
private readonly IClock _clock;

private readonly Dictionary<string, Command> _commands = new();

private readonly float _maxLoadFactor;
private readonly Duration _maxLoadFactorTimeframe;
private readonly float _additionalLoadFactorAtHighThreshold;
private Dictionary<User, TtlQueue<float>> _loadsPerUser = new();

/// <summary>
/// Create a new command processor instance
/// </summary>
/// <param name="logger">logger</param>
/// <param name="commandLogger">command logger</param>
/// <param name="argsParser">args parser instance</param>
/// <param name="clock">clock</param>
/// <param name="maxLoadFactor">maximum load factor before commands are silently dropped</param>
/// <param name="maxLoadFactorTimeframe">timeframe for which the load factor is computed</param>
/// <param name="additionalLoadFactorAtHighThreshold">
/// additional load to add to the load factor when a user is at their maximum load capacity.
/// It is linearly interpolated from 0 when there are no messages within the timeframe,
/// up to the supplied number multiplier when at the maximum amount of messages within the timeframe.
/// This is to have the load factor be more effective against continuous spam than sporadic bursts.</param>
public CommandProcessor(
ILogger<CommandProcessor> logger,
ICommandLogger commandLogger,
ArgsParser argsParser)
ArgsParser argsParser,
IClock clock,
float maxLoadFactor = 200f,
Duration? maxLoadFactorTimeframe = null,
float additionalLoadFactorAtHighThreshold = 3f)
{
_logger = logger;
_commandLogger = commandLogger;
_argsParser = argsParser;
_clock = clock;
_maxLoadFactor = maxLoadFactor;
_maxLoadFactorTimeframe = maxLoadFactorTimeframe ?? Duration.FromMinutes(10);
_additionalLoadFactorAtHighThreshold = additionalLoadFactorAtHighThreshold;
}

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

private float CheckAndUpdateLoadFactorForUser(User user)
{
_loadsPerUser = _loadsPerUser
.Where(kvp => kvp.Value.Count > 0)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
if (!_loadsPerUser.TryGetValue(user, out TtlQueue<float>? loads))
{
loads = new TtlQueue<float>(_maxLoadFactorTimeframe, _clock);
_loadsPerUser[user] = loads;
}
float sum = loads.Sum();
float ratioFilled = Math.Min(1, sum / _maxLoadFactor);
float toAdd = 1 + ratioFilled * _additionalLoadFactorAtHighThreshold;
loads.Enqueue(toAdd);
return sum + toAdd;
}

public async Task<CommandResult?> Process(string commandName, IImmutableList<string> args, Message message)
{
float loadFactor = CheckAndUpdateLoadFactorForUser(message.User);
_logger.LogDebug("new load factor is {LoadFactor}", loadFactor);
if (loadFactor > _maxLoadFactor)
{
_logger.LogDebug(
"command '{Command}' from user {User} ignored because load factor is {LoadFactor} " +
"for timeframe {Duration}, which is above the maximum of {MaxLoadFactor}",
commandName, message.User, loadFactor, _maxLoadFactorTimeframe, _maxLoadFactor);
return new CommandResult();
}
if (!_commands.TryGetValue(commandName.ToLower(), out Command command))
{
_logger.LogDebug("unknown command '{Command}'", commandName);
Expand Down
2 changes: 1 addition & 1 deletion TPP.Core/Setups.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public static ICommandProcessor SetUpCommandProcessor(
{
ICommandProcessor commandProcessor = new CommandProcessor(
loggerFactory.CreateLogger<CommandProcessor>(),
databases.CommandLogger, argsParser);
databases.CommandLogger, argsParser, SystemClock.Instance);

IEnumerable<Command> commands = new[]
{
Expand Down
77 changes: 65 additions & 12 deletions tests/TPP.Core.Tests/Commands/CommandProcessorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,20 @@ public class CommandProcessorTest
private readonly ILogger<CommandProcessor> _nullLogger = new NullLogger<CommandProcessor>();
private readonly Mock<ICommandLogger> _commandLoggerMock = new();
private readonly ImmutableList<string> _noArgs = ImmutableList<string>.Empty;
private readonly User _mockUser = new User(
private static User MockUser() => new(
id: Guid.NewGuid().ToString(),
name: "MockUser", twitchDisplayName: "☺MockUser", simpleName: "mockuser", color: null,
firstActiveAt: Instant.FromUnixTimeSeconds(0), lastActiveAt: Instant.FromUnixTimeSeconds(0),
lastMessageAt: null, pokeyen: 0, tokens: 0);
private readonly User _mockUser = MockUser();

private Message MockMessage(string text = "")
=> new Message(_mockUser, text, MessageSource.Chat, string.Empty);
private Message MockMessage(string text = "") => new(_mockUser, text, MessageSource.Chat, string.Empty);

[Test]
public async Task TestUnknownCommand()
{
var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser());
var commandProcessor = new CommandProcessor(
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());

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

Expand All @@ -44,7 +45,8 @@ public async Task TestUnknownCommand()
public async Task TestLogSlowCommand()
{
var loggerMock = new Mock<ILogger<CommandProcessor>>();
var commandProcessor = new CommandProcessor(loggerMock.Object, _commandLoggerMock.Object, new ArgsParser());
var commandProcessor = new CommandProcessor(
loggerMock.Object, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
commandProcessor.InstallCommand(new Command("slow", async _ =>
{
await Task.Delay(TimeSpan.FromMilliseconds(1050));
Expand All @@ -62,7 +64,8 @@ public async Task TestLogSlowCommand()
public async Task TestCommandThrowsError()
{
var loggerMock = new Mock<ILogger<CommandProcessor>>();
var commandProcessor = new CommandProcessor(loggerMock.Object, _commandLoggerMock.Object, new ArgsParser());
var commandProcessor = new CommandProcessor(
loggerMock.Object, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
commandProcessor.InstallCommand(new Command("broken",
_ => throw new InvalidOperationException("this command is busted!")));

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

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

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

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

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

commandProcessor.InstallCommand(new Command(
"a", CommandUtils.StaticResponse("Hi!")) { Aliases = new[] { "b" } });
Expand All @@ -140,7 +148,8 @@ public void InstallConflictNameVsAlias()
[Test]
public async Task TestPermissions()
{
var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser());
var commandProcessor = new CommandProcessor(
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of<IClock>());
commandProcessor.InstallCommand(
new Command("opsonly", CommandUtils.StaticResponse("you are an operator")).WithCondition(
canExecute: ctx => IsOperator(ctx.Message.User),
Expand All @@ -161,5 +170,49 @@ bool IsOperator(User user) =>
"opsonly", _noArgs, new Message(op, "", MessageSource.Chat, ""));
Assert.That(opResult?.Response, Is.EqualTo("you are an operator"));
}

[Test]
public async Task MaxCommandsPerUser()
{
Mock<IClock> clockMock = new();
var commandProcessor = new CommandProcessor(
_nullLogger, _commandLoggerMock.Object, new ArgsParser(), clockMock.Object,
maxLoadFactor: 6, maxLoadFactorTimeframe: Duration.FromSeconds(10),
additionalLoadFactorAtHighThreshold: 6);

commandProcessor.InstallCommand(new Command("foo",
_ => Task.FromResult(new CommandResult {Response = "yes!"})));

clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(0));
CommandResult? resultOk1 = await commandProcessor.Process(
"foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, ""));

// has +1 additional load factor because the load factor is already at 1/6, which * 6 additional load is 1
// result is a total load of 3
clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(5));
CommandResult? resultOk2 = await commandProcessor.Process(
"foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, ""));

// at 50% load already. this gets rejected and adds an additional +3 load (50% of additional 6 load)
// result is a total load of 7
clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(10));
CommandResult? resultNo = await commandProcessor.Process(
"foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, ""));

// make sure this is per-user
CommandResult? resultOkOtherUser = await commandProcessor.Process(
"foo", ImmutableList.Create(""), new Message(MockUser(), "", MessageSource.Chat, ""));

// letting everything so far expire lets the user use commands again
clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(21));
CommandResult? resultOk3 = await commandProcessor.Process(
"foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, ""));

Assert.That(resultOk1?.Response, Is.EqualTo("yes!"));
Assert.That(resultOk2?.Response, Is.EqualTo("yes!"));
Assert.That(resultNo?.Response, Is.Null);
Assert.That(resultOkOtherUser?.Response, Is.EqualTo("yes!"));
Assert.That(resultOk3?.Response, Is.EqualTo("yes!"));
}
}
}

0 comments on commit 4da9ff5

Please sign in to comment.