diff --git a/.idea/.idea.Rucksack/.idea/indexLayout.xml b/.idea/.idea.Rucksack/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.Rucksack/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.Rucksack/.idea/vcs.xml b/.idea/.idea.Rucksack/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.Rucksack/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 15be63e..bd8b6d5 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ using Rucksack.LoadStrategies; var options = new LoadTestOptions { LoadStrategy = new RepeatLoadStrategy( - CountPerStep: 10, - Interval: TimeSpan.FromSeconds(1), - TotalDuration: TimeSpan.FromSeconds(10)), + countPerInterval: 10, + interval: TimeSpan.FromSeconds(1), + totalDuration: TimeSpan.FromSeconds(10)), }; await LoadTestRunner.Run(async () => @@ -31,3 +31,4 @@ There are a couple built-in strategies for generating load, or you can implement * `OneShotLoadStrategy`: Enqueue the given number of tasks all at once. Does not repeat. * `RepeatLoadStrategy`: Repeat enqueueing the given count of tasks at each given interval until the given duration has passed. +* `SteppedBurstLoadStrategy`: Enqueue an increasing (or decreasing) burst of tasks in a stepwise manner, regardless of how many are still running. diff --git a/Rucksack.Tests/Rucksack.Tests.csproj b/Rucksack.Tests/Rucksack.Tests.csproj index 74753f7..b7047ad 100644 --- a/Rucksack.Tests/Rucksack.Tests.csproj +++ b/Rucksack.Tests/Rucksack.Tests.csproj @@ -6,15 +6,22 @@ enable false true + true - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Rucksack.Tests/Strategies/OneShotLoadStrategyTests.cs b/Rucksack.Tests/Strategies/OneShotLoadStrategyTests.cs index 1153477..d860806 100644 --- a/Rucksack.Tests/Strategies/OneShotLoadStrategyTests.cs +++ b/Rucksack.Tests/Strategies/OneShotLoadStrategyTests.cs @@ -11,19 +11,20 @@ public async Task Step_WithCountOf1_CallsActionOnce() // Arrange var actionCalledCount = 0; var strategy = new OneShotLoadStrategy(1); + var context = new LoadStrategyContext(PreviousResult: null); // Act - var result = strategy.Step(() => + var result = strategy.GenerateLoad(() => { Interlocked.Increment(ref actionCalledCount); return ValueTask.FromResult(new LoadTaskResult(TimeSpan.Zero)); - }, null); + }, context); await StrategyTestHelper.ExecuteStrategyResultAndWait(result); // Assert actionCalledCount.Should().Be(1); - result.NextStepDelay.Should().BeNull(); + result.RepeatDelay.Should().BeNull(); } [Fact] @@ -32,18 +33,19 @@ public async Task Step_WithCountOf3_CallsActionThreeTimes() // Arrange var actionCalledCount = 0; var strategy = new OneShotLoadStrategy(3); + var context = new LoadStrategyContext(PreviousResult: null); // Act - var result = strategy.Step(() => + var result = strategy.GenerateLoad(() => { Interlocked.Increment(ref actionCalledCount); return ValueTask.FromResult(new LoadTaskResult(TimeSpan.Zero)); - }, null); + }, context); await StrategyTestHelper.ExecuteStrategyResultAndWait(result); // Assert actionCalledCount.Should().Be(3); - result.NextStepDelay.Should().BeNull(); + result.RepeatDelay.Should().BeNull(); } } diff --git a/Rucksack.Tests/Strategies/RepeatLoadStrategyTests.cs b/Rucksack.Tests/Strategies/RepeatLoadStrategyTests.cs index a8db49c..2e991c7 100644 --- a/Rucksack.Tests/Strategies/RepeatLoadStrategyTests.cs +++ b/Rucksack.Tests/Strategies/RepeatLoadStrategyTests.cs @@ -16,24 +16,25 @@ public async Task Step_WithCountOf1_CallsActionOnce() Interlocked.Increment(ref actionCalledCount); return ValueTask.FromResult(new LoadTaskResult(TimeSpan.Zero)); }; + var context = new LoadStrategyContext(PreviousResult: null); // Act - var result = strategy.Step(action, null); + var result = strategy.GenerateLoad(action, context); var tasks = await StrategyTestHelper.ExecuteStrategyResult(result); // Assert - result.NextStepDelay.Should().Be(TimeSpan.FromSeconds(1)); + result.RepeatDelay.Should().Be(TimeSpan.FromSeconds(1)); // Call again - result = strategy.Step(action, result); + result = strategy.GenerateLoad(action, new LoadStrategyContext(PreviousResult: result)); tasks.AddRange(await StrategyTestHelper.ExecuteStrategyResult(result)); await StrategyTestHelper.WhenAll(tasks); // Assert actionCalledCount.Should().Be(1); - result.NextStepDelay.Should().BeNull(); + result.RepeatDelay.Should().BeNull(); } [Fact] @@ -47,23 +48,24 @@ public async Task Step_WithCountOf3_CallsActionThreeTimes() Interlocked.Increment(ref actionCalledCount); return ValueTask.FromResult(new LoadTaskResult(TimeSpan.Zero)); }; + var context = new LoadStrategyContext(PreviousResult: null); // Act - var result = strategy.Step(action, null); + var result = strategy.GenerateLoad(action, context); var tasks = await StrategyTestHelper.ExecuteStrategyResult(result); // Assert - result.NextStepDelay.Should().Be(TimeSpan.FromSeconds(1)); + result.RepeatDelay.Should().Be(TimeSpan.FromSeconds(1)); // Call again - result = strategy.Step(action, result); + result = strategy.GenerateLoad(action, new LoadStrategyContext(PreviousResult: result)); tasks.AddRange(await StrategyTestHelper.ExecuteStrategyResult(result)); await StrategyTestHelper.WhenAll(tasks); // Assert actionCalledCount.Should().Be(3); - result.NextStepDelay.Should().BeNull(); + result.RepeatDelay.Should().BeNull(); } } diff --git a/Rucksack.Tests/Strategies/SteppedBurstLoadStrategyTests.cs b/Rucksack.Tests/Strategies/SteppedBurstLoadStrategyTests.cs new file mode 100644 index 0000000..27fd78a --- /dev/null +++ b/Rucksack.Tests/Strategies/SteppedBurstLoadStrategyTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using Rucksack.LoadStrategies; + +namespace Rucksack.Tests.Strategies; + +public class SteppedBurstLoadStrategyTests +{ + [InlineData(1, 1, 2, 3)] // generated load = [1, 2] for a total of 3 + [InlineData(2, 2, 4, 6)] // generated load = [2, 4] for a total of 6 + [InlineData(10, 10, 50, 150)] // generated load = [10, 20, 30, 40, 50] for a total of 150 + [InlineData(-10, 50, 10, 150)] // generated load = [50, 40, 30, 20, 10] for a total of 150 + [InlineData(-1, 2, 1, 3)] // generated load = [2, 1] for a total of 3 + [Theory] + public async Task SteppedBurstLoadStrategy_FullTests(int step, int from, int to, int expectedCount) + { + // Arrange + var actionCalledCount = 0; + var strategy = new SteppedBurstLoadStrategy(step, from, to, interval: TimeSpan.FromSeconds(1)); + var action = () => + { + Interlocked.Increment(ref actionCalledCount); + return ValueTask.FromResult(new LoadTaskResult(TimeSpan.Zero)); + }; + + // Act + LoadStrategyResult? result = null; + List> tasks = []; + + do + { + var context = new LoadStrategyContext(PreviousResult: result); + result = strategy.GenerateLoad(action, context); + + if (result.Tasks is { } resultTasks) + { + tasks.AddRange(resultTasks); + } + + if (result.RepeatDelay.HasValue) + { + await Task.Delay(result.RepeatDelay.Value); + } + } + while (result.RepeatDelay.HasValue); + + await StrategyTestHelper.WhenAll(tasks); + + // Assert + result.RepeatDelay.Should().BeNull(); + actionCalledCount.Should().Be(expectedCount); + } + + [InlineData(0, 1, 0, false)] + [InlineData(0, 1, 1, true)] + [InlineData(0, 1, 2, true)] + [InlineData(1, 0, 1, false)] + [InlineData(1, 0, 0, true)] + [InlineData(1, 0, -1, true)] + [InlineData(10, 100, 99, false)] + [InlineData(10, 100, 101, true)] + [InlineData(100, 10, 11, false)] + [InlineData(100, 10, 8, true)] + [Theory] + public void IsFinishedTests(int from, int to, int current, bool expected) + { + // Act + var result = SteppedBurstLoadStrategy.IsFinished(from, to, current); + + // Assert + result.Should().Be(expected); + } +} diff --git a/Rucksack.Tests/Strategies/StrategyTestHelper.cs b/Rucksack.Tests/Strategies/StrategyTestHelper.cs index f433c2d..ddbf3d7 100644 --- a/Rucksack.Tests/Strategies/StrategyTestHelper.cs +++ b/Rucksack.Tests/Strategies/StrategyTestHelper.cs @@ -19,9 +19,9 @@ public static async ValueTask WhenAll(IEnumerable> tas public static async ValueTask>> ExecuteStrategyResult(LoadStrategyResult result) { - if (result.NextStepDelay.HasValue) + if (result.RepeatDelay.HasValue) { - await Task.Delay(result.NextStepDelay.Value); + await Task.Delay(result.RepeatDelay.Value); } return [..result.Tasks ?? []]; diff --git a/Rucksack/ILoadStrategy.cs b/Rucksack/ILoadStrategy.cs index dd43e8f..5ca175c 100644 --- a/Rucksack/ILoadStrategy.cs +++ b/Rucksack/ILoadStrategy.cs @@ -2,5 +2,5 @@ namespace Rucksack; public interface ILoadStrategy { - LoadStrategyResult Step(Func> action, LoadStrategyResult? previousResult); + LoadStrategyResult GenerateLoad(Func> action, LoadStrategyContext context); } diff --git a/Rucksack/LoadStrategies/OneShotLoadStrategy.cs b/Rucksack/LoadStrategies/OneShotLoadStrategy.cs index 8553c21..11aabb8 100644 --- a/Rucksack/LoadStrategies/OneShotLoadStrategy.cs +++ b/Rucksack/LoadStrategies/OneShotLoadStrategy.cs @@ -2,19 +2,17 @@ namespace Rucksack.LoadStrategies; public class OneShotLoadStrategy(int count) : ILoadStrategy { - public int Count { get; } = count; - - public LoadStrategyResult Step(Func> action, LoadStrategyResult? previousResult) + public LoadStrategyResult GenerateLoad(Func> action, LoadStrategyContext context) { - if (previousResult is not null) + if (context.PreviousResult is not null) { throw new InvalidOperationException("OneShotLoadStrategy does not support previous results."); } - var tasks = Enumerable.Range(0, Count) + var tasks = Enumerable.Range(0, count) .Select(_ => action()) .ToArray(); - return new LoadStrategyResult(NextStepDelay: null, Tasks: tasks); + return new LoadStrategyResult(RepeatDelay: null, Tasks: tasks); } } diff --git a/Rucksack/LoadStrategies/RepeatLoadStrategy.cs b/Rucksack/LoadStrategies/RepeatLoadStrategy.cs index 2257ddf..de3f562 100644 --- a/Rucksack/LoadStrategies/RepeatLoadStrategy.cs +++ b/Rucksack/LoadStrategies/RepeatLoadStrategy.cs @@ -2,21 +2,21 @@ namespace Rucksack.LoadStrategies; -public class RepeatLoadStrategy(int CountPerStep, TimeSpan Interval, TimeSpan TotalDuration) +public class RepeatLoadStrategy(int countPerInterval, TimeSpan interval, TimeSpan totalDuration) : ILoadStrategy { - public LoadStrategyResult Step(Func> action, LoadStrategyResult? previousResult) + public LoadStrategyResult GenerateLoad(Func> action, LoadStrategyContext context) { RepeatLoadStrategyResult result; int iteration = 1; - if (previousResult is null) + if (context.PreviousResult is null) { - result = new RepeatLoadStrategyResult(Interval, Stopwatch.StartNew(), iteration, null); + result = new RepeatLoadStrategyResult(interval, Stopwatch.StartNew(), iteration, null); } - else if (previousResult is not RepeatLoadStrategyResult previousRepeatResult) + else if (context.PreviousResult is not RepeatLoadStrategyResult previousRepeatResult) { - throw new ArgumentException($"Expected {nameof(RepeatLoadStrategyResult)} but got {previousResult.GetType().Name}", nameof(previousResult)); + throw new ArgumentException($"Expected previous result type {nameof(RepeatLoadStrategyResult)} but got {context.PreviousResult.GetType().Name}", nameof(context)); } else { @@ -24,12 +24,12 @@ public LoadStrategyResult Step(Func> action, LoadStrat iteration = result.Iteration + 1; } - if (result.Stopwatch.Elapsed >= TotalDuration) + if (result.Stopwatch.Elapsed >= totalDuration) { return LoadStrategyResult.Finished; } - var tasks = Enumerable.Range(0, CountPerStep) + var tasks = Enumerable.Range(0, countPerInterval) .Select(_ => action()) .ToArray(); @@ -40,6 +40,6 @@ public LoadStrategyResult Step(Func> action, LoadStrat }; } - private record RepeatLoadStrategyResult(TimeSpan? NextStepDelay, Stopwatch Stopwatch, int Iteration, IReadOnlyList>? Tasks) - : LoadStrategyResult(NextStepDelay, Tasks); + private record RepeatLoadStrategyResult(TimeSpan? RepeatDelay, Stopwatch Stopwatch, int Iteration, IReadOnlyList>? Tasks) + : LoadStrategyResult(RepeatDelay, Tasks); } diff --git a/Rucksack/LoadStrategies/SteppedBurstLoadStrategy.cs b/Rucksack/LoadStrategies/SteppedBurstLoadStrategy.cs new file mode 100644 index 0000000..ff6b838 --- /dev/null +++ b/Rucksack/LoadStrategies/SteppedBurstLoadStrategy.cs @@ -0,0 +1,73 @@ +namespace Rucksack.LoadStrategies; + +public class SteppedBurstLoadStrategy : ILoadStrategy +{ + private readonly int _step; + private readonly int _from; + private readonly int _to; + private readonly TimeSpan _interval; + + public SteppedBurstLoadStrategy(int step, int from, int to, TimeSpan interval) + { + if (from == to) + { + throw new ArgumentException( + "From and to values must be different. " + + $"If you just need to fire a burst once, consider using {nameof(OneShotLoadStrategy)}.", + nameof(from)); + } + + if (from < to && step <= 0) + { + throw new ArgumentException("Step must be greater than 0 when from is less than to.", nameof(step)); + } + + if (from > to && step >= 0) + { + throw new ArgumentException("Step must be less than 0 when from is greater than to.", nameof(step)); + } + + _step = step; + _from = from; + _to = to; + _interval = interval; + } + + public LoadStrategyResult GenerateLoad(Func> action, LoadStrategyContext context) + { + SteppedLoadStrategyResult result; + int currentCount = _from; + + if (context.PreviousResult is null) + { + result = new SteppedLoadStrategyResult(_interval, currentCount, null); + } + else if (context.PreviousResult is not SteppedLoadStrategyResult previousSteppedResult) + { + throw new ArgumentException($"Expected previous result type {nameof(SteppedLoadStrategyResult)} but got {context.PreviousResult.GetType().Name}", nameof(context)); + } + else + { + result = previousSteppedResult; + currentCount = result.CurrentCount + _step; + } + + var tasks = Enumerable.Range(0, currentCount) + .Select(_ => action()) + .ToArray(); + + return result with + { + RepeatDelay = IsFinished(_from, _to, currentCount) ? null : _interval, + CurrentCount = currentCount, + Tasks = tasks, + }; + } + + internal static bool IsFinished(int from, int to, int currentCount) => + (from < to && currentCount >= to) + || (from > to && currentCount <= to); + + private record SteppedLoadStrategyResult(TimeSpan? RepeatDelay, int CurrentCount, IReadOnlyList>? Tasks) + : LoadStrategyResult(RepeatDelay, Tasks); +} diff --git a/Rucksack/LoadStrategyContext.cs b/Rucksack/LoadStrategyContext.cs new file mode 100644 index 0000000..66e7eca --- /dev/null +++ b/Rucksack/LoadStrategyContext.cs @@ -0,0 +1,3 @@ +namespace Rucksack; + +public record LoadStrategyContext(LoadStrategyResult? PreviousResult); diff --git a/Rucksack/LoadStrategyResult.cs b/Rucksack/LoadStrategyResult.cs index e27e031..687e424 100644 --- a/Rucksack/LoadStrategyResult.cs +++ b/Rucksack/LoadStrategyResult.cs @@ -1,6 +1,6 @@ namespace Rucksack; -public record LoadStrategyResult(TimeSpan? NextStepDelay, IReadOnlyList>? Tasks) +public record LoadStrategyResult(TimeSpan? RepeatDelay, IReadOnlyList>? Tasks) { public static LoadStrategyResult Finished { get; } = new(null, null); } diff --git a/Rucksack/LoadTestRunner.cs b/Rucksack/LoadTestRunner.cs index 4462a7f..7ab2895 100644 --- a/Rucksack/LoadTestRunner.cs +++ b/Rucksack/LoadTestRunner.cs @@ -28,7 +28,9 @@ public static async Task Run(Func action, LoadTestOptions options) while (true) { - result = options.LoadStrategy.Step(LoadAction, result); + var context = new LoadStrategyContext(PreviousResult: result); + + result = options.LoadStrategy.GenerateLoad(LoadAction, context); if (result.Tasks is { } tasks) { @@ -37,12 +39,12 @@ public static async Task Run(Func action, LoadTestOptions options) allTasks.AddRange(tasks); } - if (result.NextStepDelay == null) + if (result.RepeatDelay == null) { break; } - await Task.Delay(result.NextStepDelay.Value); + await Task.Delay(result.RepeatDelay.Value); } logger.LogInformation("Waiting for {Count} tasks to complete...", allTasks.Count); diff --git a/Rucksack/Rucksack.csproj b/Rucksack/Rucksack.csproj index 380ca0b..98d2e00 100644 --- a/Rucksack/Rucksack.csproj +++ b/Rucksack/Rucksack.csproj @@ -4,8 +4,13 @@ net8.0 enable enable + true + + + +