Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ await AssertThrows<ArgumentOutOfRangeException>(() =>
// The result variant previously skipped this validation entirely
await AssertThrows<ArgumentOutOfRangeException>(() =>
new[] { 1 }.SelectAsync(i => Task.FromResult(i)).ProcessInParallel(0, TimeSpan.FromSeconds(1)));

await AssertThrows<ArgumentOutOfRangeException>(() =>
new[] { 1 }.SelectAsync(i => Task.FromResult(i)).ProcessInParallel(1, TimeSpan.FromSeconds(1), 0));
}

[Test]
Expand Down
66 changes: 66 additions & 0 deletions EnumerableAsyncProcessor.UnitTests/WorkerPoolBehaviourTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -133,6 +134,71 @@ public async Task Timed_RateLimited_Processor_Cancels_Unprocessed_Items_Promptly
await processor.DisposeAsync();
}

[Test, Timeout(10_000)]
public async Task Timed_Rate_Limit_Allows_Concurrency_Independent_Of_Permit_Count(CancellationToken cancellationToken)
{
const int itemCount = 6;

var startedCount = 0;
var allStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

await using var processor = Enumerable.Range(0, itemCount).ToList()
.ForEachAsync(async _ =>
{
if (Interlocked.Increment(ref startedCount) == itemCount)
{
allStarted.TrySetResult();
}

await release.Task;
}, cancellationToken)
.ProcessInParallel(
permitsPerWindow: 2,
window: TimeSpan.FromMilliseconds(100),
maxConcurrency: itemCount);

try
{
await allStarted.Task.WaitAsync(TimeSpan.FromSeconds(3), cancellationToken);
}
finally
{
release.TrySetResult();
}

await processor.WaitAsync();

await Assert.That(startedCount).IsEqualTo(itemCount);
}

[Test, Retry(3), Timeout(10_000)]
public async Task Timed_Rate_Limit_Does_Not_Exceed_Permits_At_Replenishment(CancellationToken cancellationToken)
{
const int permitsPerWindow = 3;
var window = TimeSpan.FromMilliseconds(200);
var startedAt = new ConcurrentBag<TimeSpan>();
var stopwatch = Stopwatch.StartNew();

await using var processor = Enumerable.Range(0, 9).ToList()
.ForEachAsync(_ =>
{
startedAt.Add(stopwatch.Elapsed);
return Task.CompletedTask;
}, cancellationToken)
.ProcessInParallel(permitsPerWindow, window, maxConcurrency: 9);

await processor.WaitAsync();

var orderedStarts = startedAt.OrderBy(x => x).ToArray();

for (var i = permitsPerWindow; i < orderedStarts.Length; i++)
{
await Assert.That(orderedStarts[i] - orderedStarts[i - permitsPerWindow])
.IsGreaterThan(window / 2);
}
}

[Test]
public async Task Result_Order_Is_Preserved_Regardless_Of_Completion_Order()
{
Expand Down
11 changes: 10 additions & 1 deletion EnumerableAsyncProcessor/Builders/ActionAsyncProcessorBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ public IAsyncProcessor ProcessInBatches(int batchSize)

public IAsyncProcessor ProcessInParallel(int maxConcurrency, TimeSpan timeSpan)
{
return new TimedRateLimitedParallelAsyncProcessor(_count, _taskSelector, maxConcurrency, timeSpan, _cancellationTokenSource).StartProcessing();
return ProcessInParallel(maxConcurrency, timeSpan, maxConcurrency);
}

/// <summary>Processes tasks with independent start-rate and concurrency limits.</summary>
/// <param name="permitsPerWindow">Maximum operations that may start in each window.</param>
/// <param name="window">Rate-limit replenishment window.</param>
/// <param name="maxConcurrency">Maximum operations that may remain in flight.</param>
public IAsyncProcessor ProcessInParallel(int permitsPerWindow, TimeSpan window, int maxConcurrency)
{
return new TimedRateLimitedParallelAsyncProcessor(_count, _taskSelector, permitsPerWindow, window, maxConcurrency, _cancellationTokenSource).StartProcessing();
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ public IAsyncProcessor<TOutput> ProcessInBatches(int batchSize)

public IAsyncProcessor<TOutput> ProcessInParallel(int maxConcurrency, TimeSpan timeSpan)
{
return new ResultTimedRateLimitedParallelAsyncProcessor<TOutput>(_count, _taskSelector, maxConcurrency, timeSpan, _cancellationTokenSource).StartProcessing();
return ProcessInParallel(maxConcurrency, timeSpan, maxConcurrency);
}

/// <summary>Processes tasks with independent start-rate and concurrency limits.</summary>
/// <param name="permitsPerWindow">Maximum operations that may start in each window.</param>
/// <param name="window">Rate-limit replenishment window.</param>
/// <param name="maxConcurrency">Maximum operations that may remain in flight.</param>
public IAsyncProcessor<TOutput> ProcessInParallel(int permitsPerWindow, TimeSpan window, int maxConcurrency)
{
return new ResultTimedRateLimitedParallelAsyncProcessor<TOutput>(_count, _taskSelector, permitsPerWindow, window, maxConcurrency, _cancellationTokenSource).StartProcessing();
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,16 @@ public IAsyncProcessor ProcessInBatches(int batchSize)

public IAsyncProcessor ProcessInParallel(int maxConcurrency, TimeSpan timeSpan)
{
return new TimedRateLimitedParallelAsyncProcessor<TInput>(_items, _taskSelector, maxConcurrency, timeSpan, _cancellationTokenSource)
return ProcessInParallel(maxConcurrency, timeSpan, maxConcurrency);
}

/// <summary>Processes items with independent start-rate and concurrency limits.</summary>
/// <param name="permitsPerWindow">Maximum operations that may start in each window.</param>
/// <param name="window">Rate-limit replenishment window.</param>
/// <param name="maxConcurrency">Maximum operations that may remain in flight.</param>
public IAsyncProcessor ProcessInParallel(int permitsPerWindow, TimeSpan window, int maxConcurrency)
{
return new TimedRateLimitedParallelAsyncProcessor<TInput>(_items, _taskSelector, permitsPerWindow, window, maxConcurrency, _cancellationTokenSource)
.StartProcessing();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,19 @@ public IAsyncProcessor<TOutput> ProcessInBatches(int batchSize)
/// </remarks>
public IAsyncProcessor<TOutput> ProcessInParallel(int maxConcurrency, TimeSpan timeSpan)
{
return new ResultTimedRateLimitedParallelAsyncProcessor<TInput, TOutput>(_items, _taskSelector, maxConcurrency, timeSpan, _cancellationTokenSource).StartProcessing();
return ProcessInParallel(maxConcurrency, timeSpan, maxConcurrency);
}

/// <summary>
/// Processes items with independent start-rate and concurrency limits.
/// </summary>
/// <param name="permitsPerWindow">Maximum operations that may start in each window.</param>
/// <param name="window">Rate-limit replenishment window.</param>
/// <param name="maxConcurrency">Maximum operations that may remain in flight.</param>
/// <returns>An async processor that implements IDisposable and IAsyncDisposable.</returns>
public IAsyncProcessor<TOutput> ProcessInParallel(int permitsPerWindow, TimeSpan window, int maxConcurrency)
{
return new ResultTimedRateLimitedParallelAsyncProcessor<TInput, TOutput>(_items, _taskSelector, permitsPerWindow, window, maxConcurrency, _cancellationTokenSource).StartProcessing();
}

/// <summary>
Expand Down
1 change: 1 addition & 0 deletions EnumerableAsyncProcessor/EnumerableAsyncProcessor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<None Include="$(MSBuildThisFileDirectory)..\README.md" Pack="true" PackagePath="\" />

<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.301" PrivateAssets="All"/>
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.10" />

</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper =>

// Throttled processing runs on a fixed worker pool: P worker tasks instead of
// one queued task, closure and semaphore wait per item
await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, minimumIterationTime: null, CancellationToken).ConfigureAwait(false);
await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, rateLimiter: null, CancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper =>

// Throttled processing runs on a fixed worker pool: P worker tasks instead of
// one queued task, closure and semaphore wait per item
await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, minimumIterationTime: null, CancellationToken).ConfigureAwait(false);
await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, rateLimiter: null, CancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper =>

// Throttled processing runs on a fixed worker pool: P worker tasks instead of
// one queued task, closure and semaphore wait per item
await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, minimumIterationTime: null, CancellationToken).ConfigureAwait(false);
await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, rateLimiter: null, CancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper =>

// Throttled processing runs on a fixed worker pool: P worker tasks instead of
// one queued task, closure and semaphore wait per item
await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, minimumIterationTime: null, CancellationToken).ConfigureAwait(false);
await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, rateLimiter: null, CancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors;

public class ResultTimedRateLimitedParallelAsyncProcessor<TOutput> : ResultAbstractAsyncProcessor<TOutput>
{
private readonly int _levelsOfParallelism;
private readonly TimeSpan _timeSpan;
private readonly int _permitsPerWindow;
private readonly TimeSpan _window;
private readonly int _maxConcurrency;

internal ResultTimedRateLimitedParallelAsyncProcessor(int count, Func<Task<TOutput>> taskSelector, int levelsOfParallelism, TimeSpan timeSpan, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource)
internal ResultTimedRateLimitedParallelAsyncProcessor(int count, Func<Task<TOutput>> taskSelector, int permitsPerWindow, TimeSpan window, int maxConcurrency, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource)
{
ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism);
ValidationHelper.ThrowIfNegative(timeSpan);
ValidationHelper.ThrowIfNegativeOrZero(permitsPerWindow);
ValidationHelper.ThrowIfNegative(window);
ValidationHelper.ThrowIfNegativeOrZero(maxConcurrency);

_levelsOfParallelism = levelsOfParallelism;
_timeSpan = timeSpan;
_permitsPerWindow = permitsPerWindow;
_window = window;
_maxConcurrency = maxConcurrency;
}

internal override Task Process()
{
return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken);
return WorkerPool.ProcessRateLimitedAsync(TaskWrappers, _maxConcurrency, _permitsPerWindow, _window, CancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors;

public class ResultTimedRateLimitedParallelAsyncProcessor<TInput, TOutput> : ResultAbstractAsyncProcessor<TInput, TOutput>
{
private readonly int _levelsOfParallelism;
private readonly TimeSpan _timeSpan;
private readonly int _permitsPerWindow;
private readonly TimeSpan _window;
private readonly int _maxConcurrency;

internal ResultTimedRateLimitedParallelAsyncProcessor(IEnumerable<TInput> items, Func<TInput, Task<TOutput>> taskSelector, int levelsOfParallelism, TimeSpan timeSpan, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource)
internal ResultTimedRateLimitedParallelAsyncProcessor(IEnumerable<TInput> items, Func<TInput, Task<TOutput>> taskSelector, int permitsPerWindow, TimeSpan window, int maxConcurrency, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource)
{
ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism);
ValidationHelper.ThrowIfNegative(timeSpan);
ValidationHelper.ThrowIfNegativeOrZero(permitsPerWindow);
ValidationHelper.ThrowIfNegative(window);
ValidationHelper.ThrowIfNegativeOrZero(maxConcurrency);

_levelsOfParallelism = levelsOfParallelism;
_timeSpan = timeSpan;
_permitsPerWindow = permitsPerWindow;
_window = window;
_maxConcurrency = maxConcurrency;
}

internal override Task Process()
{
return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken);
return WorkerPool.ProcessRateLimitedAsync(TaskWrappers, _maxConcurrency, _permitsPerWindow, _window, CancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ namespace EnumerableAsyncProcessor.RunnableProcessors;

public class TimedRateLimitedParallelAsyncProcessor : AbstractAsyncProcessor
{
private readonly int _levelsOfParallelism;
private readonly TimeSpan _timeSpan;
private readonly int _permitsPerWindow;
private readonly TimeSpan _window;
private readonly int _maxConcurrency;

internal TimedRateLimitedParallelAsyncProcessor(int count, Func<Task> taskSelector, int levelsOfParallelism, TimeSpan timeSpan, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource)
internal TimedRateLimitedParallelAsyncProcessor(int count, Func<Task> taskSelector, int permitsPerWindow, TimeSpan window, int maxConcurrency, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource)
{
ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism);
ValidationHelper.ThrowIfNegative(timeSpan);
ValidationHelper.ThrowIfNegativeOrZero(permitsPerWindow);
ValidationHelper.ThrowIfNegative(window);
ValidationHelper.ThrowIfNegativeOrZero(maxConcurrency);

_levelsOfParallelism = levelsOfParallelism;
_timeSpan = timeSpan;
_permitsPerWindow = permitsPerWindow;
_window = window;
_maxConcurrency = maxConcurrency;
}

internal override Task Process()
{
return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken);
return WorkerPool.ProcessRateLimitedAsync(TaskWrappers, _maxConcurrency, _permitsPerWindow, _window, CancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ namespace EnumerableAsyncProcessor.RunnableProcessors;

public class TimedRateLimitedParallelAsyncProcessor<TInput> : AbstractAsyncProcessor<TInput>
{
private readonly int _levelsOfParallelism;
private readonly TimeSpan _timeSpan;
private readonly int _permitsPerWindow;
private readonly TimeSpan _window;
private readonly int _maxConcurrency;

internal TimedRateLimitedParallelAsyncProcessor(IEnumerable<TInput> items, Func<TInput, Task> taskSelector, int levelsOfParallelism, TimeSpan timeSpan, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource)
internal TimedRateLimitedParallelAsyncProcessor(IEnumerable<TInput> items, Func<TInput, Task> taskSelector, int permitsPerWindow, TimeSpan window, int maxConcurrency, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource)
{
ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism);
ValidationHelper.ThrowIfNegative(timeSpan);
ValidationHelper.ThrowIfNegativeOrZero(permitsPerWindow);
ValidationHelper.ThrowIfNegative(window);
ValidationHelper.ThrowIfNegativeOrZero(maxConcurrency);

_levelsOfParallelism = levelsOfParallelism;
_timeSpan = timeSpan;
_permitsPerWindow = permitsPerWindow;
_window = window;
_maxConcurrency = maxConcurrency;
}

internal override Task Process()
{
return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken);
return WorkerPool.ProcessRateLimitedAsync(TaskWrappers, _maxConcurrency, _permitsPerWindow, _window, CancellationToken);
}
}
Loading
Loading