From a303676c100b8c8bba2e1fda5a89893eb35950c2 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:36:05 +0100 Subject: [PATCH 01/11] fix: correctness issues in processor core - Materialize task wrappers once at construction. Input enumerables were lazily re-enumerated up to 5 times (validation Any(), Process, WhenAll, CancelAll, dispose), breaking one-shot sources and re-running side effects. Removes the ConcurrentDictionary identity workaround and the eager .Any() probe of lazy enumerables. - Fix disposal never cancelling: _disposed was set before CancelAll, whose guard then made it a no-op, so dispose waited up to 30s for tasks to finish naturally. - Result processors registered sync-over-async Dispose as the cancellation callback, blocking the cancelling thread up to 30s. Both bases now register CancelAll, and only at Start() so a token cancelled during construction cannot fire on a partially constructed instance (NRE). - Route Process() faults into unfinished completion sources instead of discarding them; awaiters previously hung forever if item enumeration or cancellation plumbing threw. - Create TaskCompletionSources with RunContinuationsAsynchronously to stop user continuations running inline on the completing thread. - Preserve all task exceptions via InnerExceptions instead of GetBaseException, which dropped all but the first fault. - Sync Dispose no longer blocks via Task.Run().Wait(30s); it cancels and releases immediately. - Consistent cancellation token flow in rate-limited and timed processors (was a mix of None and missing tokens). --- .../Extensions/AsyncProcessorExtensions.cs | 10 +- .../Abstract/AbstractAsyncProcessor.cs | 33 ++-- .../Abstract/AbstractAsyncProcessorBase.cs | 162 +++++++++--------- .../Abstract/AbstractAsyncProcessor_1.cs | 42 ++--- .../RateLimitedParallelAsyncProcessor.cs | 10 +- .../RateLimitedParallelAsyncProcessor_1.cs | 10 +- .../ResultAbstractAsyncProcessorBase.cs | 153 ++++++++--------- .../ResultAbstractAsyncProcessor_1.cs | 34 ++-- .../ResultAbstractAsyncProcessor_2.cs | 42 ++--- ...sultRateLimitedParallelAsyncProcessor_1.cs | 10 +- ...sultRateLimitedParallelAsyncProcessor_2.cs | 11 +- ...imedRateLimitedParallelAsyncProcessor_1.cs | 16 +- ...imedRateLimitedParallelAsyncProcessor_2.cs | 16 +- .../TimedRateLimitedParallelAsyncProcessor.cs | 15 +- ...imedRateLimitedParallelAsyncProcessor_1.cs | 15 +- .../TaskCompletionSource.cs | 14 +- EnumerableAsyncProcessor/TaskWrapper.cs | 148 +++++++--------- .../Validation/ValidationHelper.cs | 23 --- 18 files changed, 319 insertions(+), 445 deletions(-) diff --git a/EnumerableAsyncProcessor/Extensions/AsyncProcessorExtensions.cs b/EnumerableAsyncProcessor/Extensions/AsyncProcessorExtensions.cs index aa046eb..e8695a5 100644 --- a/EnumerableAsyncProcessor/Extensions/AsyncProcessorExtensions.cs +++ b/EnumerableAsyncProcessor/Extensions/AsyncProcessorExtensions.cs @@ -1,4 +1,4 @@ -using EnumerableAsyncProcessor.Interfaces; +using EnumerableAsyncProcessor.Interfaces; using EnumerableAsyncProcessor.RunnableProcessors.Abstract; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; @@ -8,13 +8,13 @@ internal static class AsyncProcessorExtensions { internal static IAsyncProcessor StartProcessing(this AbstractAsyncProcessorBase processor) { - _ = processor.Process(); + processor.Start(); return processor; } - + internal static IAsyncProcessor StartProcessing(this ResultAbstractAsyncProcessorBase processor) { - _ = processor.Process(); + processor.Start(); return processor; } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs index 6e58973..d67afef 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs @@ -1,41 +1,30 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.Abstract; public abstract class AbstractAsyncProcessor : AbstractAsyncProcessorBase { - private readonly ConcurrentDictionary _taskCompletionSources = []; + protected readonly IReadOnlyList TaskWrappers; - protected readonly IEnumerable TaskWrappers; + private readonly TaskCompletionSource[] _taskCompletionSources; - [field: AllowNull, MaybeNull] - protected override IEnumerable EnumerableTaskCompletionSources - => field ??= TaskWrappers.Select(x => x.TaskCompletionSource); + protected override IReadOnlyList EnumerableTaskCompletionSources => _taskCompletionSources; - protected AbstractAsyncProcessor(int count, Func taskSelector, CancellationTokenSource cancellationTokenSource) : base(cancellationTokenSource) { ValidationHelper.ValidateCount(count); ValidationHelper.ThrowIfNull(taskSelector); - // Provide optimization for empty collections - if (count == 0) - { - TaskWrappers = []; - return; - } + var taskWrappers = new ActionTaskWrapper[count]; + _taskCompletionSources = new TaskCompletionSource[count]; - // Provide performance warning for very large collections - var warning = ValidationHelper.GetPerformanceWarning(count); - if (warning != null) + for (var i = 0; i < count; i++) { - // In a real application, you might want to log this warning - // For now, we'll just store it as a comment that could be used by logging - _ = warning; + var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _taskCompletionSources[i] = taskCompletionSource; + taskWrappers[i] = new ActionTaskWrapper(taskSelector, taskCompletionSource); } - TaskWrappers = Enumerable.Range(0, count).Select(index => new ActionTaskWrapper(taskSelector, _taskCompletionSources.GetOrAdd(index, new TaskCompletionSource()))); + TaskWrappers = taskWrappers; } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessorBase.cs b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessorBase.cs index f02468d..f0916a5 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessorBase.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessorBase.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using EnumerableAsyncProcessor.Interfaces; using EnumerableAsyncProcessor.Validation; @@ -7,35 +6,65 @@ namespace EnumerableAsyncProcessor.RunnableProcessors.Abstract; public abstract class AbstractAsyncProcessorBase : IAsyncProcessor, IAsyncDisposable, IDisposable { - protected abstract IEnumerable EnumerableTaskCompletionSources { get; } + private static readonly TimeSpan DisposalTimeout = TimeSpan.FromSeconds(30); + + protected abstract IReadOnlyList EnumerableTaskCompletionSources { get; } protected readonly CancellationToken CancellationToken; - [field: MaybeNull, AllowNull] - private IEnumerable EnumerableTasks => field ??= EnumerableTaskCompletionSources.Select(x => x.Task); - private readonly CancellationTokenSource _cancellationTokenSource; + private CancellationTokenRegistration _cancellationTokenRegistration; + private Task? _overallTask; + private Task? _processTask; private volatile bool _disposed; private readonly object _disposeLock = new(); - [field: AllowNull, MaybeNull] - private Task OverallTask => field ??= Task.WhenAll(EnumerableTasks); - + private Task OverallTask => _overallTask ??= Task.WhenAll(EnumerableTaskCompletionSources.Select(x => x.Task)); + protected AbstractAsyncProcessorBase(CancellationTokenSource cancellationTokenSource) { ValidationHelper.ValidateCancellationTokenSource(cancellationTokenSource); - - CancellationToken = cancellationTokenSource.Token; - CancellationToken.Register(CancelAll); - CancellationToken.ThrowIfCancellationRequested(); - + _cancellationTokenSource = cancellationTokenSource; + CancellationToken = cancellationTokenSource.Token; } internal abstract Task Process(); - + + // Cancellation is registered here rather than in the constructor so that a token cancelled + // during construction can never invoke CancelAll on a partially constructed instance. + internal void Start() + { + _cancellationTokenRegistration = CancellationToken.Register(CancelAll); + _processTask = RunProcess(); + } + + private async Task RunProcess() + { + try + { + await Process().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + foreach (var taskCompletionSource in EnumerableTaskCompletionSources) + { + taskCompletionSource.TrySetCanceled(CancellationToken); + } + } + catch (Exception exception) + { + // A failure outside the per-item wrappers (e.g. cancellation plumbing) would otherwise + // leave awaiters of the per-item tasks hanging forever. + foreach (var taskCompletionSource in EnumerableTaskCompletionSources) + { + taskCompletionSource.TrySetException(exception); + } + } + } + public IEnumerable GetEnumerableTasks() { - return EnumerableTasks; + return EnumerableTaskCompletionSources.Select(x => x.Task); } public TaskAwaiter GetAwaiter() @@ -47,28 +76,30 @@ public Task WaitAsync() { return OverallTask; } - + public void CancelAll() { if (_disposed) return; - + + CancelAllCore(); + } + + private void CancelAllCore() + { if (!_cancellationTokenSource.IsCancellationRequested) { _cancellationTokenSource.Cancel(); } - foreach (var tcs in EnumerableTaskCompletionSources) + foreach (var taskCompletionSource in EnumerableTaskCompletionSources) { - tcs.TrySetCanceled(CancellationToken); + taskCompletionSource.TrySetCanceled(CancellationToken); } } public async ValueTask DisposeAsync() { - if (_disposed) - return; - lock (_disposeLock) { if (_disposed) @@ -79,60 +110,24 @@ public async ValueTask DisposeAsync() // Allow derived classes to dispose their resources first await DisposeAsyncCore().ConfigureAwait(false); - // Cancel all operations - CancelAll(); + CancelAllCore(); + _cancellationTokenRegistration.Dispose(); - // Wait for all running tasks to complete with timeout - try + // Give in-flight tasks a bounded window to observe cancellation and finish + if (_processTask is { IsCompleted: false }) { - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - var allTasks = EnumerableTasks.ToList(); - - if (allTasks.Count > 0) + try { - var completionTasks = allTasks.Select(async task => - { - try - { - await task.WaitAsync(timeoutCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected when cancelled - ignore - } - catch (Exception) - { - // Task exceptions are expected - ignore during disposal - } - }).ToList(); - - if (completionTasks.Count > 0) - { - try - { - await Task.WhenAll(completionTasks).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Timeout occurred - continue with disposal - } - } + using var timeoutCts = new CancellationTokenSource(DisposalTimeout); + await _processTask.WaitAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Timed out waiting for in-flight tasks - continue with disposal } - } - catch (Exception) - { - // Swallow exceptions during disposal cleanup } - // Dispose the cancellation token source - try - { - _cancellationTokenSource.Dispose(); - } - catch (Exception) - { - // Swallow disposal exceptions - } + _cancellationTokenSource.Dispose(); GC.SuppressFinalize(this); } @@ -148,20 +143,19 @@ protected virtual ValueTask DisposeAsyncCore() public void Dispose() { - // Use Task.Run to avoid deadlocks by running async disposal on thread pool - // Add timeout to prevent indefinite blocking - try - { - var disposeTask = Task.Run(async () => await DisposeAsync().ConfigureAwait(false)); - if (!disposeTask.Wait(TimeSpan.FromSeconds(30))) - { - // Log warning if disposal times out, but don't throw - // as per IDisposable pattern - } - } - catch + lock (_disposeLock) { - // Suppress exceptions during disposal as per IDisposable pattern + if (_disposed) + return; + _disposed = true; } + + // Cancel and release without blocking; in-flight tasks complete against + // already-cancelled completion sources, which is a no-op. + CancelAllCore(); + _cancellationTokenRegistration.Dispose(); + _cancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor_1.cs index 2011cc7..089ecc1 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor_1.cs @@ -1,43 +1,25 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.Abstract; public abstract class AbstractAsyncProcessor : AbstractAsyncProcessorBase { - private readonly ConcurrentDictionary _taskCompletionSources = []; - - protected readonly IEnumerable> TaskWrappers; + protected readonly IReadOnlyList> TaskWrappers; + + private readonly TaskCompletionSource[] _taskCompletionSources; + + protected override IReadOnlyList EnumerableTaskCompletionSources => _taskCompletionSources; - [field: AllowNull, MaybeNull] - protected override IEnumerable EnumerableTaskCompletionSources - => field ??= TaskWrappers.Select(x => x.TaskCompletionSource); - protected AbstractAsyncProcessor(IEnumerable items, Func taskSelector, CancellationTokenSource cancellationTokenSource) : base(cancellationTokenSource) { - var isEmpty = ValidationHelper.ValidateEnumerable(items); + ValidationHelper.ThrowIfNull(items); ValidationHelper.ThrowIfNull(taskSelector); - // Provide optimization for empty collections - if (isEmpty) - { - TaskWrappers = []; - return; - } - - // Get count for performance warnings if collection implements ICollection - if (items is ICollection collection) - { - var warning = ValidationHelper.GetPerformanceWarning(collection.Count); - if (warning != null) - { - // In a real application, you might want to log this warning - // For now, we'll just store it as a comment that could be used by logging - _ = warning; - } - } + // Materialize once so one-shot or side-effecting enumerables are only enumerated a single time + TaskWrappers = items + .Select(item => new ItemTaskWrapper(item, taskSelector, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously))) + .ToArray(); - TaskWrappers = items.Select((item, index) => new ItemTaskWrapper(item, taskSelector, _taskCompletionSources.GetOrAdd(index, new TaskCompletionSource()))); + _taskCompletionSources = TaskWrappers.Select(x => x.TaskCompletionSource).ToArray(); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs index 190208d..0dba4f0 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs @@ -17,11 +17,9 @@ internal RateLimitedParallelAsyncProcessor(int count, Func taskSelector, i internal override Task Process() { - // For rate-limited processing, we want strict parallelism control - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - async taskWrapper => - { - await Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken).ConfigureAwait(false); - }, CancellationToken); + // Task.Run guards the shared worker slots against synchronous code in user delegates + return TaskWrappers.InParallelAsync(_levelsOfParallelism, + taskWrapper => Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), + CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs index 69971bc..5478bcc 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs @@ -17,11 +17,9 @@ internal RateLimitedParallelAsyncProcessor(IEnumerable items, Func - { - await Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken).ConfigureAwait(false); - }, CancellationToken); + // Task.Run guards the shared worker slots against synchronous code in user delegates + return TaskWrappers.InParallelAsync(_levelsOfParallelism, + taskWrapper => Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), + CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessorBase.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessorBase.cs index 2fc608b..cd0751a 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessorBase.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessorBase.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using EnumerableAsyncProcessor.Interfaces; using EnumerableAsyncProcessor.Extensions; @@ -8,36 +7,65 @@ namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; public abstract class ResultAbstractAsyncProcessorBase : IAsyncProcessor, IAsyncDisposable, IDisposable { - protected abstract IEnumerable> EnumerableTaskCompletionSources { get; } + private static readonly TimeSpan DisposalTimeout = TimeSpan.FromSeconds(30); + + protected abstract IReadOnlyList> EnumerableTaskCompletionSources { get; } protected readonly CancellationToken CancellationToken; - [field: MaybeNull, AllowNull] - private IEnumerable> EnumerableTasks => field ??= EnumerableTaskCompletionSources.Select(x => x.Task); - private readonly CancellationTokenSource _cancellationTokenSource; + private CancellationTokenRegistration _cancellationTokenRegistration; + private Task? _results; + private Task? _processTask; private volatile bool _disposed; private readonly object _disposeLock = new(); - - [field: AllowNull, MaybeNull] - private Task Results => field ??= Task.WhenAll(EnumerableTasks); + private Task Results => _results ??= Task.WhenAll(EnumerableTaskCompletionSources.Select(x => x.Task)); protected ResultAbstractAsyncProcessorBase(CancellationTokenSource cancellationTokenSource) { ValidationHelper.ValidateCancellationTokenSource(cancellationTokenSource); - + _cancellationTokenSource = cancellationTokenSource; - CancellationToken = cancellationTokenSource.Token; - CancellationToken.Register(Dispose); - CancellationToken.ThrowIfCancellationRequested(); } internal abstract Task Process(); - + + // Cancellation is registered here rather than in the constructor so that a token cancelled + // during construction can never invoke CancelAll on a partially constructed instance. + internal void Start() + { + _cancellationTokenRegistration = CancellationToken.Register(CancelAll); + _processTask = RunProcess(); + } + + private async Task RunProcess() + { + try + { + await Process().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + foreach (var taskCompletionSource in EnumerableTaskCompletionSources) + { + taskCompletionSource.TrySetCanceled(CancellationToken); + } + } + catch (Exception exception) + { + // A failure outside the per-item wrappers (e.g. cancellation plumbing) would otherwise + // leave awaiters of the per-item tasks hanging forever. + foreach (var taskCompletionSource in EnumerableTaskCompletionSources) + { + taskCompletionSource.TrySetException(exception); + } + } + } + public IEnumerable> GetEnumerableTasks() { - return EnumerableTasks; + return EnumerableTaskCompletionSources.Select(x => x.Task); } public Task GetResultsAsync() @@ -59,7 +87,12 @@ public void CancelAll() { if (_disposed) return; - + + CancelAllCore(); + } + + private void CancelAllCore() + { if (!_cancellationTokenSource.IsCancellationRequested) { _cancellationTokenSource.Cancel(); @@ -73,9 +106,6 @@ public void CancelAll() public async ValueTask DisposeAsync() { - if (_disposed) - return; - lock (_disposeLock) { if (_disposed) @@ -86,60 +116,24 @@ public async ValueTask DisposeAsync() // Allow derived classes to dispose their resources first await DisposeAsyncCore().ConfigureAwait(false); - // Cancel all operations - CancelAll(); + CancelAllCore(); + _cancellationTokenRegistration.Dispose(); - // Wait for all running tasks to complete with timeout - try + // Give in-flight tasks a bounded window to observe cancellation and finish + if (_processTask is { IsCompleted: false }) { - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - var allTasks = EnumerableTasks.ToList(); - - if (allTasks.Count > 0) + try { - var completionTasks = allTasks.Select(async task => - { - try - { - await task.WaitAsync(timeoutCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected when cancelled - ignore - } - catch (Exception) - { - // Task exceptions are expected - ignore during disposal - } - }).ToList(); - - if (completionTasks.Count > 0) - { - try - { - await Task.WhenAll(completionTasks).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Timeout occurred - continue with disposal - } - } + using var timeoutCts = new CancellationTokenSource(DisposalTimeout); + await _processTask.WaitAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Timed out waiting for in-flight tasks - continue with disposal } - } - catch (Exception) - { - // Swallow exceptions during disposal cleanup } - // Dispose the cancellation token source - try - { - _cancellationTokenSource.Dispose(); - } - catch (Exception) - { - // Swallow disposal exceptions - } + _cancellationTokenSource.Dispose(); GC.SuppressFinalize(this); } @@ -155,20 +149,19 @@ protected virtual ValueTask DisposeAsyncCore() public void Dispose() { - // Use Task.Run to avoid deadlocks by running async disposal on thread pool - // Add timeout to prevent indefinite blocking - try - { - var disposeTask = Task.Run(async () => await DisposeAsync().ConfigureAwait(false)); - if (!disposeTask.Wait(TimeSpan.FromSeconds(30))) - { - // Log warning if disposal times out, but don't throw - // as per IDisposable pattern - } - } - catch + lock (_disposeLock) { - // Suppress exceptions during disposal as per IDisposable pattern + if (_disposed) + return; + _disposed = true; } + + // Cancel and release without blocking; in-flight tasks complete against + // already-cancelled completion sources, which is a no-op. + CancelAllCore(); + _cancellationTokenRegistration.Dispose(); + _cancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs index de1ff16..d3cceb9 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs @@ -1,40 +1,30 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; public abstract class ResultAbstractAsyncProcessor : ResultAbstractAsyncProcessorBase { - private readonly ConcurrentDictionary> _taskCompletionSources = []; + protected readonly IReadOnlyList> TaskWrappers; - protected readonly IEnumerable> TaskWrappers; + private readonly TaskCompletionSource[] _taskCompletionSources; + + protected override IReadOnlyList> EnumerableTaskCompletionSources => _taskCompletionSources; protected ResultAbstractAsyncProcessor(int count, Func> taskSelector, CancellationTokenSource cancellationTokenSource) : base(cancellationTokenSource) { ValidationHelper.ValidateCount(count); ValidationHelper.ThrowIfNull(taskSelector); - // Provide optimization for empty collections - if (count == 0) - { - TaskWrappers = []; - return; - } + var taskWrappers = new ActionTaskWrapper[count]; + _taskCompletionSources = new TaskCompletionSource[count]; - // Provide performance warning for very large collections - var warning = ValidationHelper.GetPerformanceWarning(count); - if (warning != null) + for (var i = 0; i < count; i++) { - // In a real application, you might want to log this warning - // For now, we'll just store it as a comment that could be used by logging - _ = warning; + var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _taskCompletionSources[i] = taskCompletionSource; + taskWrappers[i] = new ActionTaskWrapper(taskSelector, taskCompletionSource); } - TaskWrappers = Enumerable.Range(0, count).Select(index => new ActionTaskWrapper(taskSelector, _taskCompletionSources.GetOrAdd(index, new TaskCompletionSource()))); + TaskWrappers = taskWrappers; } - - [field: AllowNull, MaybeNull] - protected override IEnumerable> EnumerableTaskCompletionSources => - field ??= TaskWrappers.Select(x => x.TaskCompletionSource); -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_2.cs index 02322d6..d9110e4 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_2.cs @@ -1,43 +1,25 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; public abstract class ResultAbstractAsyncProcessor : ResultAbstractAsyncProcessorBase { - private readonly ConcurrentDictionary> _taskCompletionSources = []; + protected readonly IReadOnlyList> TaskWrappers; + + private readonly TaskCompletionSource[] _taskCompletionSources; + + protected override IReadOnlyList> EnumerableTaskCompletionSources => _taskCompletionSources; - protected readonly IEnumerable> TaskWrappers; - protected ResultAbstractAsyncProcessor(IEnumerable items, Func> taskSelector, CancellationTokenSource cancellationTokenSource) : base(cancellationTokenSource) { - var isEmpty = ValidationHelper.ValidateEnumerable(items); + ValidationHelper.ThrowIfNull(items); ValidationHelper.ThrowIfNull(taskSelector); - // Provide optimization for empty collections - if (isEmpty) - { - TaskWrappers = []; - return; - } + // Materialize once so one-shot or side-effecting enumerables are only enumerated a single time + TaskWrappers = items + .Select(item => new ItemTaskWrapper(item, taskSelector, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously))) + .ToArray(); - // Get count for performance warnings if collection implements ICollection - if (items is ICollection collection) - { - var warning = ValidationHelper.GetPerformanceWarning(collection.Count); - if (warning != null) - { - // In a real application, you might want to log this warning - // For now, we'll just store it as a comment that could be used by logging - _ = warning; - } - } - - TaskWrappers = items.Select((item, index) => new ItemTaskWrapper(item, taskSelector, _taskCompletionSources.GetOrAdd(index, new TaskCompletionSource()))); + _taskCompletionSources = TaskWrappers.Select(x => x.TaskCompletionSource).ToArray(); } - - [field: AllowNull, MaybeNull] - protected override IEnumerable> EnumerableTaskCompletionSources => - field ??= TaskWrappers.Select(x => x.TaskCompletionSource); -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs index d951b64..ca6b4a4 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs @@ -15,11 +15,9 @@ internal ResultRateLimitedParallelAsyncProcessor(int count, Func> internal override Task Process() { - // For rate-limited processing, we want strict parallelism control - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - async taskWrapper => - { - await Task.Run(() => taskWrapper.Process(CancellationToken)).ConfigureAwait(false); - }, CancellationToken.None); + // Task.Run guards the shared worker slots against synchronous code in user delegates + return TaskWrappers.InParallelAsync(_levelsOfParallelism, + taskWrapper => Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), + CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs index 62f781d..ded90e4 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs @@ -14,12 +14,9 @@ internal ResultRateLimitedParallelAsyncProcessor(IEnumerable items, Func internal override Task Process() { - // For rate-limited processing, we want strict parallelism control - // TaskWrapper.Process already includes Task.Yield to prevent thread pool blocking - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - async taskWrapper => - { - await taskWrapper.Process(CancellationToken).ConfigureAwait(false); - }, CancellationToken.None); + // Task.Run guards the shared worker slots against synchronous code in user delegates + return TaskWrappers.InParallelAsync(_levelsOfParallelism, + taskWrapper => Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), + CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs index b335070..f183580 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs @@ -16,14 +16,12 @@ internal ResultTimedRateLimitedParallelAsyncProcessor(int count, Func - { - await Task.WhenAll( - taskWrapper.Process(CancellationToken), - Task.Delay(_timeSpan, CancellationToken)).ConfigureAwait(false); - }, CancellationToken.None); + // Each worker slot holds an item for at least _timeSpan to honour the rate limit. + // Task.Run guards the shared worker slots against synchronous code in user delegates + return TaskWrappers.InParallelAsync(_levelsOfParallelism, + taskWrapper => Task.WhenAll( + Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), + Task.Delay(_timeSpan, CancellationToken)), + CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs index 4f51b0f..0e0685c 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs @@ -16,14 +16,12 @@ internal ResultTimedRateLimitedParallelAsyncProcessor(IEnumerable items, internal override Task Process() { - // For timed rate-limited processing, we want strict parallelism control - // TaskWrapper.Process already includes Task.Yield to prevent thread pool blocking - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - async taskWrapper => - { - await Task.WhenAll( - taskWrapper.Process(CancellationToken), - Task.Delay(_timeSpan, CancellationToken)).ConfigureAwait(false); - }, CancellationToken.None); + // Each worker slot holds an item for at least _timeSpan to honour the rate limit. + // Task.Run guards the shared worker slots against synchronous code in user delegates + return TaskWrappers.InParallelAsync(_levelsOfParallelism, + taskWrapper => Task.WhenAll( + Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), + Task.Delay(_timeSpan, CancellationToken)), + CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs index e16c64b..b8b3285 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs @@ -20,13 +20,12 @@ internal TimedRateLimitedParallelAsyncProcessor(int count, Func taskSelect internal override Task Process() { - // For timed rate-limited processing, we want strict parallelism control - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - async taskWrapper => - { - await Task.WhenAll( - Task.Run(() => taskWrapper.Process(CancellationToken)), - Task.Delay(_timeSpan, CancellationToken)).ConfigureAwait(false); - }, CancellationToken.None); + // Each worker slot holds an item for at least _timeSpan to honour the rate limit. + // Task.Run guards the shared worker slots against synchronous code in user delegates + return TaskWrappers.InParallelAsync(_levelsOfParallelism, + taskWrapper => Task.WhenAll( + Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), + Task.Delay(_timeSpan, CancellationToken)), + CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs index d520ab3..c8eb84b 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs @@ -20,13 +20,12 @@ internal TimedRateLimitedParallelAsyncProcessor(IEnumerable items, Func< internal override Task Process() { - // For timed rate-limited processing, we want strict parallelism control - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - async taskWrapper => - { - await Task.WhenAll( - Task.Run(() => taskWrapper.Process(CancellationToken)), - Task.Delay(_timeSpan, CancellationToken)).ConfigureAwait(false); - }, CancellationToken.None); + // Each worker slot holds an item for at least _timeSpan to honour the rate limit. + // Task.Run guards the shared worker slots against synchronous code in user delegates + return TaskWrappers.InParallelAsync(_levelsOfParallelism, + taskWrapper => Task.WhenAll( + Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), + Task.Delay(_timeSpan, CancellationToken)), + CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/TaskCompletionSource.cs b/EnumerableAsyncProcessor/TaskCompletionSource.cs index b67eaf1..c32f1f7 100644 --- a/EnumerableAsyncProcessor/TaskCompletionSource.cs +++ b/EnumerableAsyncProcessor/TaskCompletionSource.cs @@ -1,10 +1,20 @@ -#if NETSTANDARD2_0 +#if NETSTANDARD2_0 namespace EnumerableAsyncProcessor { /// public class TaskCompletionSource : TaskCompletionSource { + /// + public TaskCompletionSource() + { + } + + /// + public TaskCompletionSource(TaskCreationOptions creationOptions) : base(creationOptions) + { + } + /// public bool TrySetResult() { @@ -21,4 +31,4 @@ public void SetResult() public new Task Task => base.Task; } } -#endif \ No newline at end of file +#endif diff --git a/EnumerableAsyncProcessor/TaskWrapper.cs b/EnumerableAsyncProcessor/TaskWrapper.cs index 1d36ec3..a86654f 100644 --- a/EnumerableAsyncProcessor/TaskWrapper.cs +++ b/EnumerableAsyncProcessor/TaskWrapper.cs @@ -25,35 +25,28 @@ public async Task Process(CancellationToken cancellationToken) return; } + Task? task = null; try { - // Removed Task.Yield - parallelism is now handled at the processor level - var task = TaskFactory.Invoke(); - - // Fast-path for already completed tasks - if (task.IsCompleted) - { - if (task.IsFaulted) - { - TaskCompletionSource.TrySetException(task.Exception?.GetBaseException() ?? task.Exception!); - } - else if (task.IsCanceled) - { - TaskCompletionSource.TrySetCanceled(cancellationToken); - } - else - { - TaskCompletionSource.TrySetResult(); - } - return; - } - + task = TaskFactory.Invoke(); await task.ConfigureAwait(false); TaskCompletionSource.TrySetResult(); } catch (Exception e) { - TaskCompletionSource.TrySetException(e); + if (task is { IsCanceled: true }) + { + TaskCompletionSource.TrySetCanceled(cancellationToken); + } + else if (task is { IsFaulted: true }) + { + // Preserve every failure from the task, not just the first + TaskCompletionSource.TrySetException(task.Exception!.InnerExceptions); + } + else + { + TaskCompletionSource.TrySetException(e); + } } } @@ -120,35 +113,28 @@ public async Task Process(CancellationToken cancellationToken) return; } + Task? task = null; try { - // Removed Task.Yield - parallelism is now handled at the processor level - var task = TaskFactory.Invoke(Input); - - // Fast-path for already completed tasks - if (task.IsCompleted) - { - if (task.IsFaulted) - { - TaskCompletionSource.TrySetException(task.Exception?.GetBaseException() ?? task.Exception!); - } - else if (task.IsCanceled) - { - TaskCompletionSource.TrySetCanceled(cancellationToken); - } - else - { - TaskCompletionSource.TrySetResult(); - } - return; - } - + task = TaskFactory.Invoke(Input); await task.ConfigureAwait(false); TaskCompletionSource.TrySetResult(); } catch (Exception e) { - TaskCompletionSource.TrySetException(e); + if (task is { IsCanceled: true }) + { + TaskCompletionSource.TrySetCanceled(cancellationToken); + } + else if (task is { IsFaulted: true }) + { + // Preserve every failure from the task, not just the first + TaskCompletionSource.TrySetException(task.Exception!.InnerExceptions); + } + else + { + TaskCompletionSource.TrySetException(e); + } } } @@ -218,34 +204,27 @@ public async Task Process(CancellationToken cancellationToken) return; } + Task? task = null; try { - // Removed Task.Yield - parallelism is now handled at the processor level - var task = TaskFactory.Invoke(Input); - - // Fast-path for already completed tasks - if (task.IsCompleted) - { - if (task.IsFaulted) - { - TaskCompletionSource.TrySetException(task.Exception?.GetBaseException() ?? task.Exception!); - } - else if (task.IsCanceled) - { - TaskCompletionSource.TrySetCanceled(cancellationToken); - } - else - { - TaskCompletionSource.TrySetResult(task.Result); - } - return; - } - + task = TaskFactory.Invoke(Input); TaskCompletionSource.TrySetResult(await task.ConfigureAwait(false)); } catch (Exception e) { - TaskCompletionSource.TrySetException(e); + if (task is { IsCanceled: true }) + { + TaskCompletionSource.TrySetCanceled(cancellationToken); + } + else if (task is { IsFaulted: true }) + { + // Preserve every failure from the task, not just the first + TaskCompletionSource.TrySetException(task.Exception!.InnerExceptions); + } + else + { + TaskCompletionSource.TrySetException(e); + } } } @@ -313,34 +292,27 @@ public async Task Process(CancellationToken cancellationToken) return; } + Task? task = null; try { - // Removed Task.Yield - parallelism is now handled at the processor level - var task = TaskFactory.Invoke(); - - // Fast-path for already completed tasks - if (task.IsCompleted) - { - if (task.IsFaulted) - { - TaskCompletionSource.TrySetException(task.Exception?.GetBaseException() ?? task.Exception!); - } - else if (task.IsCanceled) - { - TaskCompletionSource.TrySetCanceled(cancellationToken); - } - else - { - TaskCompletionSource.TrySetResult(task.Result); - } - return; - } - + task = TaskFactory.Invoke(); TaskCompletionSource.TrySetResult(await task.ConfigureAwait(false)); } catch (Exception e) { - TaskCompletionSource.TrySetException(e); + if (task is { IsCanceled: true }) + { + TaskCompletionSource.TrySetCanceled(cancellationToken); + } + else if (task is { IsFaulted: true }) + { + // Preserve every failure from the task, not just the first + TaskCompletionSource.TrySetException(task.Exception!.InnerExceptions); + } + else + { + TaskCompletionSource.TrySetException(e); + } } } diff --git a/EnumerableAsyncProcessor/Validation/ValidationHelper.cs b/EnumerableAsyncProcessor/Validation/ValidationHelper.cs index 3c2c488..0c4d5cc 100644 --- a/EnumerableAsyncProcessor/Validation/ValidationHelper.cs +++ b/EnumerableAsyncProcessor/Validation/ValidationHelper.cs @@ -196,29 +196,6 @@ public static void ValidateTimeSpan(TimeSpan timeSpan, [CallerArgumentExpression } } - /// - /// Validates that an enumerable collection is not null and provides optimization hints. - /// - /// The type of items in the collection. - /// The collection to validate. - /// The parameter name for the exception. - /// True if the collection is empty (optimization hint). - /// Thrown when the collection is null. - public static bool ValidateEnumerable([NotNull] IEnumerable? items, [CallerArgumentExpression(nameof(items))] string? paramName = null) - { - ThrowIfNull(items, paramName); - - // Check if collection is empty for potential optimization - if (items is ICollection collection) - { - return collection.Count == 0; - } - - // For other enumerables, we need to check if it has any elements - // This is a more expensive operation but necessary for optimization - return !items.Any(); - } - /// /// Validates that a CancellationTokenSource is not null. /// From 838180a9ba6a4653686a7780a63dbff228372b61 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:40:44 +0100 Subject: [PATCH 02/11] refactor: remove dead code, arbitrary caps and unused API surface - Remove GetPerformanceWarning: computed and immediately discarded in four constructors, never observable. - Remove arbitrary validation caps (10,000 tasks, 10,000 batch size, 24h timespan) that threw on legitimate large workloads. Negative/zero checks remain. - Add missing parallelism/batch/timespan validation to the result processor variants so all processors behave alike. - Strip unused equality ceremony, Deconstruct and AggressiveInlining (a no-op on async methods) from the TaskWrapper structs. - Remove the completed-task fast paths in ParallelExtensions: rethrowing GetBaseException destroyed stack traces and dropped sibling exceptions; awaiting is equivalent and correct. - LangVersion preview -> latest now that no preview features are used. - Update README disposal notes to match the non-blocking synchronous Dispose. --- .../TaskWrapperStructValidationTests.cs | 17 -- .../EnumerableAsyncProcessor.csproj | 2 +- .../Extensions/ParallelExtensions.cs | 28 +-- .../ResultBatchAsyncProcessor_2.cs | 3 + ...sultRateLimitedParallelAsyncProcessor_1.cs | 3 + ...sultRateLimitedParallelAsyncProcessor_2.cs | 3 + ...imedRateLimitedParallelAsyncProcessor_1.cs | 4 + ...imedRateLimitedParallelAsyncProcessor_2.cs | 4 + EnumerableAsyncProcessor/TaskWrapper.cs | 185 ++---------------- .../Validation/ValidationHelper.cs | 117 ++--------- README.md | 4 +- 11 files changed, 48 insertions(+), 322 deletions(-) diff --git a/EnumerableAsyncProcessor.UnitTests/TaskWrapperStructValidationTests.cs b/EnumerableAsyncProcessor.UnitTests/TaskWrapperStructValidationTests.cs index feeedce..ccc0758 100644 --- a/EnumerableAsyncProcessor.UnitTests/TaskWrapperStructValidationTests.cs +++ b/EnumerableAsyncProcessor.UnitTests/TaskWrapperStructValidationTests.cs @@ -59,23 +59,6 @@ public async Task ItemTaskWrapper_ProcessesWithInput() await Assert.That(tcs.Task.IsCompletedSuccessfully).IsTrue(); } - [Test] - public async Task TaskWrapper_EqualityWorks() - { - // Arrange - Func taskFactory = () => Task.CompletedTask; - var tcs = new TaskCompletionSource(); - var wrapper1 = new ActionTaskWrapper(taskFactory, tcs); - var wrapper2 = new ActionTaskWrapper(taskFactory, tcs); - var wrapper3 = new ActionTaskWrapper(() => Task.CompletedTask, new TaskCompletionSource()); - - // Act & Assert - await Assert.That(wrapper1.Equals(wrapper2)).IsTrue(); - await Assert.That(wrapper1 == wrapper2).IsTrue(); - await Assert.That(wrapper1.Equals(wrapper3)).IsFalse(); - await Assert.That(wrapper1 == wrapper3).IsFalse(); - } - [Test] public async Task TaskWrapper_ArrayStorage_WorksWithoutBoxing() { diff --git a/EnumerableAsyncProcessor/EnumerableAsyncProcessor.csproj b/EnumerableAsyncProcessor/EnumerableAsyncProcessor.csproj index bed5114..aed68d7 100644 --- a/EnumerableAsyncProcessor/EnumerableAsyncProcessor.csproj +++ b/EnumerableAsyncProcessor/EnumerableAsyncProcessor.csproj @@ -4,7 +4,7 @@ net6.0;net8.0;net9.0;netstandard2.0 enable enable - preview + latest 99.99.99 diff --git a/EnumerableAsyncProcessor/Extensions/ParallelExtensions.cs b/EnumerableAsyncProcessor/Extensions/ParallelExtensions.cs index f01a4a7..df3a344 100644 --- a/EnumerableAsyncProcessor/Extensions/ParallelExtensions.cs +++ b/EnumerableAsyncProcessor/Extensions/ParallelExtensions.cs @@ -83,20 +83,8 @@ private static async Task ProcessAsync( semaphoreAcquired = true; cancellationToken.ThrowIfCancellationRequested(); - var task = taskSelector(item); - - // Fast-path optimization for already completed tasks - if (task.IsCompleted) - { - if (task.IsFaulted) - throw task.Exception?.GetBaseException() ?? task.Exception!; - if (task.IsCanceled) - throw new OperationCanceledException(); - return task.Result; - } - // Await the task directly - it's already async so no need for Task.Run - return await task.ConfigureAwait(false); + return await taskSelector(item).ConfigureAwait(false); } finally { @@ -121,20 +109,8 @@ private static async Task ProcessAsync( semaphoreAcquired = true; cancellationToken.ThrowIfCancellationRequested(); - var task = taskSelector(item); - - // Fast-path optimization for already completed tasks - if (task.IsCompleted) - { - if (task.IsFaulted) - throw task.Exception?.GetBaseException() ?? task.Exception!; - if (task.IsCanceled) - throw new OperationCanceledException(); - return; - } - // Await the task directly - it's already async so no need for Task.Run - await task.ConfigureAwait(false); + await taskSelector(item).ConfigureAwait(false); } finally { diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs index 1cd1792..3b31669 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs @@ -1,4 +1,5 @@ using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; +using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors; @@ -9,6 +10,8 @@ public class ResultBatchAsyncProcessor : ResultAbstractAsyncPro internal ResultBatchAsyncProcessor(int batchSize, IEnumerable items, Func> taskSelector, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource) { + ValidationHelper.ValidateBatchSize(batchSize); + _batchSize = batchSize; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs index ca6b4a4..f3e4c87 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs @@ -1,6 +1,7 @@  using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; +using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors; @@ -10,6 +11,8 @@ public class ResultRateLimitedParallelAsyncProcessor : ResultAbstractAs internal ResultRateLimitedParallelAsyncProcessor(int count, Func> taskSelector, int levelsOfParallelism, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource) { + ValidationHelper.ValidateParallelism(levelsOfParallelism); + _levelsOfParallelism = levelsOfParallelism; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs index ded90e4..d67d9ff 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs @@ -1,5 +1,6 @@ using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; +using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors; @@ -9,6 +10,8 @@ public class ResultRateLimitedParallelAsyncProcessor : ResultAb internal ResultRateLimitedParallelAsyncProcessor(IEnumerable items, Func> taskSelector, int levelsOfParallelism, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource) { + ValidationHelper.ValidateParallelism(levelsOfParallelism); + _levelsOfParallelism = levelsOfParallelism; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs index f183580..f43b9b3 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs @@ -1,5 +1,6 @@ using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; +using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors; @@ -10,6 +11,9 @@ public class ResultTimedRateLimitedParallelAsyncProcessor : ResultAbstr internal ResultTimedRateLimitedParallelAsyncProcessor(int count, Func> taskSelector, int levelsOfParallelism, TimeSpan timeSpan, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource) { + ValidationHelper.ValidateParallelism(levelsOfParallelism); + ValidationHelper.ValidateTimeSpan(timeSpan); + _levelsOfParallelism = levelsOfParallelism; _timeSpan = timeSpan; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs index 0e0685c..18be667 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs @@ -1,5 +1,6 @@ using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; +using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors; @@ -10,6 +11,9 @@ public class ResultTimedRateLimitedParallelAsyncProcessor : Res internal ResultTimedRateLimitedParallelAsyncProcessor(IEnumerable items, Func> taskSelector, int levelsOfParallelism, TimeSpan timeSpan, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource) { + ValidationHelper.ValidateParallelism(levelsOfParallelism); + ValidationHelper.ValidateTimeSpan(timeSpan); + _levelsOfParallelism = levelsOfParallelism; _timeSpan = timeSpan; } diff --git a/EnumerableAsyncProcessor/TaskWrapper.cs b/EnumerableAsyncProcessor/TaskWrapper.cs index a86654f..7c0d16a 100644 --- a/EnumerableAsyncProcessor/TaskWrapper.cs +++ b/EnumerableAsyncProcessor/TaskWrapper.cs @@ -1,11 +1,9 @@ -using System.Runtime.CompilerServices; - namespace EnumerableAsyncProcessor; /// -/// A high-performance struct wrapper for action tasks to reduce heap allocations. +/// A struct wrapper pairing an action task factory with its completion source. /// -public readonly struct ActionTaskWrapper : IEquatable +public readonly struct ActionTaskWrapper { public readonly Func TaskFactory; public readonly TaskCompletionSource TaskCompletionSource; @@ -16,7 +14,6 @@ public ActionTaskWrapper(Func taskFactory, TaskCompletionSource taskComple TaskCompletionSource = taskCompletionSource; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public async Task Process(CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) @@ -24,7 +21,7 @@ public async Task Process(CancellationToken cancellationToken) TaskCompletionSource.TrySetCanceled(cancellationToken); return; } - + Task? task = null; try { @@ -49,49 +46,12 @@ public async Task Process(CancellationToken cancellationToken) } } } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool Equals(ActionTaskWrapper other) => - ReferenceEquals(TaskFactory, other.TaskFactory) && - ReferenceEquals(TaskCompletionSource, other.TaskCompletionSource); - - public override bool Equals(object? obj) => - obj is ActionTaskWrapper other && Equals(other); - - public override int GetHashCode() - { -#if NETSTANDARD2_0 - unchecked - { - var hash = 17; - hash = hash * 23 + (TaskFactory?.GetHashCode() ?? 0); - hash = hash * 23 + (TaskCompletionSource?.GetHashCode() ?? 0); - return hash; - } -#else - return HashCode.Combine(TaskFactory, TaskCompletionSource); -#endif - } - - public static bool operator ==(ActionTaskWrapper left, ActionTaskWrapper right) => - left.Equals(right); - - public static bool operator !=(ActionTaskWrapper left, ActionTaskWrapper right) => - !left.Equals(right); - -#if NET6_0_OR_GREATER - public void Deconstruct(out Func taskFactory, out TaskCompletionSource taskCompletionSource) - { - taskFactory = TaskFactory; - taskCompletionSource = TaskCompletionSource; - } -#endif } /// -/// A high-performance struct wrapper for item tasks to reduce heap allocations. +/// A struct wrapper pairing an input item and its task factory with a completion source. /// -public readonly struct ItemTaskWrapper : IEquatable> +public readonly struct ItemTaskWrapper { public readonly TInput Input; public readonly Func TaskFactory; @@ -104,7 +64,6 @@ public ItemTaskWrapper(TInput input, Func taskFactory, TaskComplet TaskCompletionSource = taskCompletionSource; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public async Task Process(CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) @@ -112,7 +71,7 @@ public async Task Process(CancellationToken cancellationToken) TaskCompletionSource.TrySetCanceled(cancellationToken); return; } - + Task? task = null; try { @@ -137,52 +96,12 @@ public async Task Process(CancellationToken cancellationToken) } } } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool Equals(ItemTaskWrapper other) => - EqualityComparer.Default.Equals(Input, other.Input) && - ReferenceEquals(TaskFactory, other.TaskFactory) && - ReferenceEquals(TaskCompletionSource, other.TaskCompletionSource); - - public override bool Equals(object? obj) => - obj is ItemTaskWrapper other && Equals(other); - - public override int GetHashCode() - { -#if NETSTANDARD2_0 - unchecked - { - var hash = 17; - hash = hash * 23 + (Input?.GetHashCode() ?? 0); - hash = hash * 23 + (TaskFactory?.GetHashCode() ?? 0); - hash = hash * 23 + (TaskCompletionSource?.GetHashCode() ?? 0); - return hash; - } -#else - return HashCode.Combine(Input, TaskFactory, TaskCompletionSource); -#endif - } - - public static bool operator ==(ItemTaskWrapper left, ItemTaskWrapper right) => - left.Equals(right); - - public static bool operator !=(ItemTaskWrapper left, ItemTaskWrapper right) => - !left.Equals(right); - -#if NET6_0_OR_GREATER - public void Deconstruct(out TInput input, out Func taskFactory, out TaskCompletionSource taskCompletionSource) - { - input = Input; - taskFactory = TaskFactory; - taskCompletionSource = TaskCompletionSource; - } -#endif } /// -/// A high-performance struct wrapper for item tasks with results to reduce heap allocations. +/// A struct wrapper pairing an input item and its result-producing task factory with a completion source. /// -public readonly struct ItemTaskWrapper : IEquatable> +public readonly struct ItemTaskWrapper { public readonly TInput Input; public readonly Func> TaskFactory; @@ -195,7 +114,6 @@ public ItemTaskWrapper(TInput input, Func> taskFactory, Ta TaskCompletionSource = taskCompletionSource; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public async Task Process(CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) @@ -203,7 +121,7 @@ public async Task Process(CancellationToken cancellationToken) TaskCompletionSource.TrySetCanceled(cancellationToken); return; } - + Task? task = null; try { @@ -227,52 +145,12 @@ public async Task Process(CancellationToken cancellationToken) } } } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool Equals(ItemTaskWrapper other) => - EqualityComparer.Default.Equals(Input, other.Input) && - ReferenceEquals(TaskFactory, other.TaskFactory) && - ReferenceEquals(TaskCompletionSource, other.TaskCompletionSource); - - public override bool Equals(object? obj) => - obj is ItemTaskWrapper other && Equals(other); - - public override int GetHashCode() - { -#if NETSTANDARD2_0 - unchecked - { - var hash = 17; - hash = hash * 23 + (Input?.GetHashCode() ?? 0); - hash = hash * 23 + (TaskFactory?.GetHashCode() ?? 0); - hash = hash * 23 + (TaskCompletionSource?.GetHashCode() ?? 0); - return hash; - } -#else - return HashCode.Combine(Input, TaskFactory, TaskCompletionSource); -#endif - } - - public static bool operator ==(ItemTaskWrapper left, ItemTaskWrapper right) => - left.Equals(right); - - public static bool operator !=(ItemTaskWrapper left, ItemTaskWrapper right) => - !left.Equals(right); - -#if NET6_0_OR_GREATER - public void Deconstruct(out TInput input, out Func> taskFactory, out TaskCompletionSource taskCompletionSource) - { - input = Input; - taskFactory = TaskFactory; - taskCompletionSource = TaskCompletionSource; - } -#endif } /// -/// A high-performance struct wrapper for action tasks with results to reduce heap allocations. +/// A struct wrapper pairing a result-producing task factory with its completion source. /// -public readonly struct ActionTaskWrapper : IEquatable> +public readonly struct ActionTaskWrapper { public readonly Func> TaskFactory; public readonly TaskCompletionSource TaskCompletionSource; @@ -283,7 +161,6 @@ public ActionTaskWrapper(Func> taskFactory, TaskCompletionSource? task = null; try { @@ -315,42 +192,4 @@ public async Task Process(CancellationToken cancellationToken) } } } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool Equals(ActionTaskWrapper other) => - ReferenceEquals(TaskFactory, other.TaskFactory) && - ReferenceEquals(TaskCompletionSource, other.TaskCompletionSource); - - public override bool Equals(object? obj) => - obj is ActionTaskWrapper other && Equals(other); - - public override int GetHashCode() - { -#if NETSTANDARD2_0 - unchecked - { - var hash = 17; - hash = hash * 23 + (TaskFactory?.GetHashCode() ?? 0); - hash = hash * 23 + (TaskCompletionSource?.GetHashCode() ?? 0); - return hash; - } -#else - return HashCode.Combine(TaskFactory, TaskCompletionSource); -#endif - } - - public static bool operator ==(ActionTaskWrapper left, ActionTaskWrapper right) => - left.Equals(right); - - public static bool operator !=(ActionTaskWrapper left, ActionTaskWrapper right) => - !left.Equals(right); - -#if NET6_0_OR_GREATER - public void Deconstruct(out Func> taskFactory, out TaskCompletionSource taskCompletionSource) - { - taskFactory = TaskFactory; - taskCompletionSource = TaskCompletionSource; - } -#endif } - diff --git a/EnumerableAsyncProcessor/Validation/ValidationHelper.cs b/EnumerableAsyncProcessor/Validation/ValidationHelper.cs index 0c4d5cc..174ace6 100644 --- a/EnumerableAsyncProcessor/Validation/ValidationHelper.cs +++ b/EnumerableAsyncProcessor/Validation/ValidationHelper.cs @@ -4,45 +4,10 @@ namespace EnumerableAsyncProcessor.Validation; /// -/// Provides validation methods and constants for the EnumerableAsyncProcessor library. +/// Provides validation methods for the EnumerableAsyncProcessor library. /// internal static class ValidationHelper { - /// - /// Maximum number of concurrent tasks allowed to prevent memory issues and system overload. - /// - public const int MAX_CONCURRENT_TASKS = 10000; - - /// - /// Maximum batch size allowed for batch processors. - /// - public const int MAX_BATCH_SIZE = 10000; - - /// - /// Minimum batch size allowed for batch processors. - /// - public const int MIN_BATCH_SIZE = 1; - - /// - /// Maximum levels of parallelism allowed for parallel processors. - /// - public const int MAX_PARALLELISM = 10000; - - /// - /// Minimum levels of parallelism allowed for parallel processors. - /// - public const int MIN_PARALLELISM = 1; - - /// - /// Maximum TimeSpan value allowed for timed processors (24 hours). - /// - public static readonly TimeSpan MAX_TIMESPAN = TimeSpan.FromDays(1); - - /// - /// Minimum TimeSpan value allowed for timed processors. - /// - public static readonly TimeSpan MIN_TIMESPAN = TimeSpan.Zero; - /// /// Validates that an object is not null using modern or fallback methods. /// @@ -113,95 +78,56 @@ public static void ThrowIfNegative(TimeSpan value, [CallerArgumentExpression(nam } /// - /// Validates that a TimeSpan value is positive. - /// - /// The value to validate. - /// The parameter name for the exception. - /// Thrown when the value is negative or zero. - public static void ThrowIfNegativeOrZero(TimeSpan value, [CallerArgumentExpression(nameof(value))] string? paramName = null) - { - if (value <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(paramName, value, $"'{paramName}' must be a positive TimeSpan."); - } - } - - /// - /// Validates that a count value is within acceptable limits for concurrent processing. + /// Validates that a count of executions is valid. /// /// The count value to validate. /// The parameter name for the exception. - /// Thrown when the count is out of acceptable range. + /// Thrown when the count is negative. public static void ValidateCount(int count, [CallerArgumentExpression(nameof(count))] string? paramName = null) { ThrowIfNegative(count, paramName); - - if (count > MAX_CONCURRENT_TASKS) - { - throw new ArgumentOutOfRangeException(paramName, count, - $"'{paramName}' cannot exceed {MAX_CONCURRENT_TASKS} to prevent system overload. Consider processing in smaller batches."); - } } /// - /// Validates that a batch size is within acceptable limits. + /// Validates that a batch size is positive. /// /// The batch size to validate. /// The parameter name for the exception. - /// Thrown when the batch size is out of acceptable range. + /// Thrown when the batch size is negative or zero. public static void ValidateBatchSize(int batchSize, [CallerArgumentExpression(nameof(batchSize))] string? paramName = null) { - if (batchSize < MIN_BATCH_SIZE) - { - throw new ArgumentOutOfRangeException(paramName, batchSize, - $"'{paramName}' must be at least {MIN_BATCH_SIZE}."); - } - - if (batchSize > MAX_BATCH_SIZE) - { - throw new ArgumentOutOfRangeException(paramName, batchSize, - $"'{paramName}' cannot exceed {MAX_BATCH_SIZE} to prevent memory issues."); - } + ThrowIfNegativeOrZero(batchSize, paramName); } /// - /// Validates that a parallelism level is within acceptable limits. + /// Validates that a parallelism level is positive. /// /// The parallelism level to validate. /// The parameter name for the exception. - /// Thrown when the parallelism level is out of acceptable range. + /// Thrown when the parallelism level is negative or zero. public static void ValidateParallelism(int levelsOfParallelism, [CallerArgumentExpression(nameof(levelsOfParallelism))] string? paramName = null) { - if (levelsOfParallelism < MIN_PARALLELISM) - { - throw new ArgumentOutOfRangeException(paramName, levelsOfParallelism, - $"'{paramName}' must be at least {MIN_PARALLELISM}."); - } + ThrowIfNegativeOrZero(levelsOfParallelism, paramName); } /// - /// Validates that a TimeSpan is within acceptable limits for timed operations. + /// Validates that a TimeSpan is valid for timed operations. /// /// The TimeSpan to validate. /// The parameter name for the exception. - /// Thrown when the TimeSpan is out of acceptable range. + /// Thrown when the TimeSpan is negative. public static void ValidateTimeSpan(TimeSpan timeSpan, [CallerArgumentExpression(nameof(timeSpan))] string? paramName = null) { ThrowIfNegative(timeSpan, paramName); - - if (timeSpan > MAX_TIMESPAN) - { - throw new ArgumentOutOfRangeException(paramName, timeSpan, - $"'{paramName}' cannot exceed {MAX_TIMESPAN.TotalHours} hours."); - } } /// - /// Validates that a CancellationTokenSource is not null. + /// Validates that a CancellationTokenSource is not null and not already cancelled. /// /// The CancellationTokenSource to validate. /// The parameter name for the exception. /// Thrown when the CancellationTokenSource is null. + /// Thrown when the CancellationTokenSource has already been cancelled. public static void ValidateCancellationTokenSource([NotNull] CancellationTokenSource? cancellationTokenSource, [CallerArgumentExpression(nameof(cancellationTokenSource))] string? paramName = null) { ThrowIfNull(cancellationTokenSource, paramName); @@ -211,19 +137,4 @@ public static void ValidateCancellationTokenSource([NotNull] CancellationTokenSo throw new ArgumentException($"'{paramName}' has already been cancelled.", paramName); } } - - /// - /// Provides a warning for very large collections that might impact performance. - /// - /// The count of items to process. - /// A warning message if the count is very large, otherwise null. - public static string? GetPerformanceWarning(int count) - { - return count switch - { - > 100000 => $"Processing {count:N0} items may consume significant memory and time. Consider processing in smaller batches.", - > 50000 => $"Processing {count:N0} items may impact performance. Monitor memory usage.", - _ => null - }; - } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 3f3bcb7..813bb27 100644 --- a/README.md +++ b/README.md @@ -366,8 +366,8 @@ private static void StartProcessing(int[] input, CancellationToken token) When a processor is disposed: -1. **Cancellation**: Internal `CancellationTokenSource` is cancelled -2. **Task Waiting**: Waits up to 30 seconds for running tasks to complete +1. **Cancellation**: Internal `CancellationTokenSource` is cancelled and any unstarted tasks complete as cancelled +2. **Task Waiting**: `await DisposeAsync()` waits up to 30 seconds for in-flight tasks to finish; synchronous `Dispose()` cancels and releases without blocking 3. **Resource Cleanup**: Disposes internal resources like `CancellationTokenSource` 4. **Thread Safety**: All disposal operations are thread-safe From eda8d646e542b1bc3a2eecfbc43be6fcb3877a27 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:51:43 +0100 Subject: [PATCH 03/11] refactor: deduplicate lifecycle, fault handling and validation - Extract shared internal ProcessorLifecycle owning start/ cancel/dispose. The void and result bases cannot share an ancestor (differently typed completion sources), and the previous mirrored ~90 lines of concurrency-sensitive code had already drifted once (one base registered CancelAll, the other Dispose). Each base now supplies only its two typed fan-out loops. - Collapse the StartProcessing extension trampoline into internal instance methods on the bases; call sites keep identical syntax. - Collapse the four identical TaskWrapper catch blocks into two TrySetFromFault overloads so the fault-classification policy exists once per completion-source type. - Reuse GetEnumerableTasks() for OverallTask/Results instead of restating the projection. - Remove ValidateCount/ValidateBatchSize/ValidateParallelism/ ValidateTimeSpan forwarders that only renamed ThrowIfNegative/ThrowIfNegativeOrZero; call the primitives directly. CallerArgumentExpression keeps messages identical. - Build the wrapper/completion-source arrays the same way in all four abstract processor constructors. --- .../Extensions/AsyncProcessorExtensions.cs | 20 --- .../ProcessorLifecycle.cs | 124 ++++++++++++++++++ .../Abstract/AbstractAsyncProcessor.cs | 16 +-- .../Abstract/AbstractAsyncProcessorBase.cs | 109 +++------------ .../RunnableProcessors/BatchAsyncProcessor.cs | 2 +- .../BatchAsyncProcessor_1.cs | 2 +- .../RateLimitedParallelAsyncProcessor.cs | 4 +- .../RateLimitedParallelAsyncProcessor_1.cs | 2 +- .../ResultAbstractAsyncProcessorBase.cs | 109 +++------------ .../ResultAbstractAsyncProcessor_1.cs | 16 +-- .../ResultBatchAsyncProcessor_1.cs | 4 +- .../ResultBatchAsyncProcessor_2.cs | 2 +- ...sultRateLimitedParallelAsyncProcessor_1.cs | 4 +- ...sultRateLimitedParallelAsyncProcessor_2.cs | 2 +- ...imedRateLimitedParallelAsyncProcessor_1.cs | 6 +- ...imedRateLimitedParallelAsyncProcessor_2.cs | 4 +- .../TimedRateLimitedParallelAsyncProcessor.cs | 6 +- ...imedRateLimitedParallelAsyncProcessor_1.cs | 4 +- EnumerableAsyncProcessor/TaskWrapper.cs | 97 +++++++------- .../Validation/ValidationHelper.cs | 44 ------- 20 files changed, 234 insertions(+), 343 deletions(-) delete mode 100644 EnumerableAsyncProcessor/Extensions/AsyncProcessorExtensions.cs create mode 100644 EnumerableAsyncProcessor/ProcessorLifecycle.cs diff --git a/EnumerableAsyncProcessor/Extensions/AsyncProcessorExtensions.cs b/EnumerableAsyncProcessor/Extensions/AsyncProcessorExtensions.cs deleted file mode 100644 index e8695a5..0000000 --- a/EnumerableAsyncProcessor/Extensions/AsyncProcessorExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using EnumerableAsyncProcessor.Interfaces; -using EnumerableAsyncProcessor.RunnableProcessors.Abstract; -using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; - -namespace EnumerableAsyncProcessor.Extensions; - -internal static class AsyncProcessorExtensions -{ - internal static IAsyncProcessor StartProcessing(this AbstractAsyncProcessorBase processor) - { - processor.Start(); - return processor; - } - - internal static IAsyncProcessor StartProcessing(this ResultAbstractAsyncProcessorBase processor) - { - processor.Start(); - return processor; - } -} diff --git a/EnumerableAsyncProcessor/ProcessorLifecycle.cs b/EnumerableAsyncProcessor/ProcessorLifecycle.cs new file mode 100644 index 0000000..08c92bc --- /dev/null +++ b/EnumerableAsyncProcessor/ProcessorLifecycle.cs @@ -0,0 +1,124 @@ +using EnumerableAsyncProcessor.Validation; + +namespace EnumerableAsyncProcessor; + +/// +/// Owns the start/cancel/dispose lifecycle shared by the void and result processor base classes, +/// which cannot share a common ancestor because they fan out to differently typed completion sources. +/// +internal sealed class ProcessorLifecycle +{ + private static readonly TimeSpan DisposalTimeout = TimeSpan.FromSeconds(30); + + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly Action _trySetCanceledAll; + private readonly Action _trySetExceptionAll; + private CancellationTokenRegistration _cancellationTokenRegistration; + private Task? _processTask; + private volatile bool _disposed; + private readonly object _disposeLock = new(); + + public CancellationToken Token { get; } + + public ProcessorLifecycle(CancellationTokenSource cancellationTokenSource, Action trySetCanceledAll, Action trySetExceptionAll) + { + ValidationHelper.ValidateCancellationTokenSource(cancellationTokenSource); + + _cancellationTokenSource = cancellationTokenSource; + _trySetCanceledAll = trySetCanceledAll; + _trySetExceptionAll = trySetExceptionAll; + Token = cancellationTokenSource.Token; + } + + // Cancellation is registered here rather than at construction so that a token cancelled + // while the processor is still being constructed can never fire on a partially built instance. + public void Start(Func process) + { + _cancellationTokenRegistration = Token.Register(CancelAll); + _processTask = RunProcess(process); + } + + private async Task RunProcess(Func process) + { + try + { + await process().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _trySetCanceledAll(); + } + catch (Exception exception) + { + // A failure outside the per-item wrappers (e.g. cancellation plumbing) would otherwise + // leave awaiters of the per-item tasks hanging forever. + _trySetExceptionAll(exception); + } + } + + public void CancelAll() + { + if (_disposed) + return; + + CancelAllCore(); + } + + private void CancelAllCore() + { + if (!_cancellationTokenSource.IsCancellationRequested) + { + _cancellationTokenSource.Cancel(); + } + + _trySetCanceledAll(); + } + + public async ValueTask DisposeAsync(Func disposeAsyncCore) + { + lock (_disposeLock) + { + if (_disposed) + return; + _disposed = true; + } + + // Allow the owning processor to dispose its resources first + await disposeAsyncCore().ConfigureAwait(false); + + CancelAllCore(); + _cancellationTokenRegistration.Dispose(); + + // Give in-flight tasks a bounded window to observe cancellation and finish + if (_processTask is { IsCompleted: false }) + { + try + { + using var timeoutCts = new CancellationTokenSource(DisposalTimeout); + await _processTask.WaitAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Timed out waiting for in-flight tasks - continue with disposal + } + } + + _cancellationTokenSource.Dispose(); + } + + public void Dispose() + { + lock (_disposeLock) + { + if (_disposed) + return; + _disposed = true; + } + + // Cancel and release without blocking; in-flight tasks complete against + // already-cancelled completion sources, which is a no-op. + CancelAllCore(); + _cancellationTokenRegistration.Dispose(); + _cancellationTokenSource.Dispose(); + } +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs index d67afef..b770649 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs @@ -12,19 +12,13 @@ public abstract class AbstractAsyncProcessor : AbstractAsyncProcessorBase protected AbstractAsyncProcessor(int count, Func taskSelector, CancellationTokenSource cancellationTokenSource) : base(cancellationTokenSource) { - ValidationHelper.ValidateCount(count); + ValidationHelper.ThrowIfNegative(count); ValidationHelper.ThrowIfNull(taskSelector); - var taskWrappers = new ActionTaskWrapper[count]; - _taskCompletionSources = new TaskCompletionSource[count]; + TaskWrappers = Enumerable.Range(0, count) + .Select(_ => new ActionTaskWrapper(taskSelector, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously))) + .ToArray(); - for (var i = 0; i < count; i++) - { - var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _taskCompletionSources[i] = taskCompletionSource; - taskWrappers[i] = new ActionTaskWrapper(taskSelector, taskCompletionSource); - } - - TaskWrappers = taskWrappers; + _taskCompletionSources = TaskWrappers.Select(x => x.TaskCompletionSource).ToArray(); } } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessorBase.cs b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessorBase.cs index f0916a5..5150896 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessorBase.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessorBase.cs @@ -1,65 +1,30 @@ using System.Runtime.CompilerServices; using EnumerableAsyncProcessor.Interfaces; -using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.Abstract; public abstract class AbstractAsyncProcessorBase : IAsyncProcessor, IAsyncDisposable, IDisposable { - private static readonly TimeSpan DisposalTimeout = TimeSpan.FromSeconds(30); - protected abstract IReadOnlyList EnumerableTaskCompletionSources { get; } protected readonly CancellationToken CancellationToken; - private readonly CancellationTokenSource _cancellationTokenSource; - private CancellationTokenRegistration _cancellationTokenRegistration; + private readonly ProcessorLifecycle _lifecycle; private Task? _overallTask; - private Task? _processTask; - private volatile bool _disposed; - private readonly object _disposeLock = new(); - private Task OverallTask => _overallTask ??= Task.WhenAll(EnumerableTaskCompletionSources.Select(x => x.Task)); + private Task OverallTask => _overallTask ??= Task.WhenAll(GetEnumerableTasks()); protected AbstractAsyncProcessorBase(CancellationTokenSource cancellationTokenSource) { - ValidationHelper.ValidateCancellationTokenSource(cancellationTokenSource); - - _cancellationTokenSource = cancellationTokenSource; - CancellationToken = cancellationTokenSource.Token; + _lifecycle = new ProcessorLifecycle(cancellationTokenSource, TrySetCanceledAll, TrySetExceptionAll); + CancellationToken = _lifecycle.Token; } internal abstract Task Process(); - // Cancellation is registered here rather than in the constructor so that a token cancelled - // during construction can never invoke CancelAll on a partially constructed instance. - internal void Start() + internal IAsyncProcessor StartProcessing() { - _cancellationTokenRegistration = CancellationToken.Register(CancelAll); - _processTask = RunProcess(); - } - - private async Task RunProcess() - { - try - { - await Process().ConfigureAwait(false); - } - catch (OperationCanceledException) - { - foreach (var taskCompletionSource in EnumerableTaskCompletionSources) - { - taskCompletionSource.TrySetCanceled(CancellationToken); - } - } - catch (Exception exception) - { - // A failure outside the per-item wrappers (e.g. cancellation plumbing) would otherwise - // leave awaiters of the per-item tasks hanging forever. - foreach (var taskCompletionSource in EnumerableTaskCompletionSources) - { - taskCompletionSource.TrySetException(exception); - } - } + _lifecycle.Start(Process); + return this; } public IEnumerable GetEnumerableTasks() @@ -79,56 +44,28 @@ public Task WaitAsync() public void CancelAll() { - if (_disposed) - return; - - CancelAllCore(); + _lifecycle.CancelAll(); } - private void CancelAllCore() + private void TrySetCanceledAll() { - if (!_cancellationTokenSource.IsCancellationRequested) - { - _cancellationTokenSource.Cancel(); - } - foreach (var taskCompletionSource in EnumerableTaskCompletionSources) { taskCompletionSource.TrySetCanceled(CancellationToken); } } - public async ValueTask DisposeAsync() + private void TrySetExceptionAll(Exception exception) { - lock (_disposeLock) - { - if (_disposed) - return; - _disposed = true; - } - - // Allow derived classes to dispose their resources first - await DisposeAsyncCore().ConfigureAwait(false); - - CancelAllCore(); - _cancellationTokenRegistration.Dispose(); - - // Give in-flight tasks a bounded window to observe cancellation and finish - if (_processTask is { IsCompleted: false }) + foreach (var taskCompletionSource in EnumerableTaskCompletionSources) { - try - { - using var timeoutCts = new CancellationTokenSource(DisposalTimeout); - await _processTask.WaitAsync(timeoutCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Timed out waiting for in-flight tasks - continue with disposal - } + taskCompletionSource.TrySetException(exception); } + } - _cancellationTokenSource.Dispose(); - + public async ValueTask DisposeAsync() + { + await _lifecycle.DisposeAsync(DisposeAsyncCore).ConfigureAwait(false); GC.SuppressFinalize(this); } @@ -143,19 +80,7 @@ protected virtual ValueTask DisposeAsyncCore() public void Dispose() { - lock (_disposeLock) - { - if (_disposed) - return; - _disposed = true; - } - - // Cancel and release without blocking; in-flight tasks complete against - // already-cancelled completion sources, which is a no-op. - CancelAllCore(); - _cancellationTokenRegistration.Dispose(); - _cancellationTokenSource.Dispose(); - + _lifecycle.Dispose(); GC.SuppressFinalize(this); } } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor.cs index 267a83f..06f9f10 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor.cs @@ -9,7 +9,7 @@ public class BatchAsyncProcessor : AbstractAsyncProcessor internal BatchAsyncProcessor(int batchSize, int count, Func taskSelector, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateBatchSize(batchSize); + ValidationHelper.ThrowIfNegativeOrZero(batchSize); _batchSize = batchSize; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor_1.cs index 3f36f84..6b5dfd4 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor_1.cs @@ -10,7 +10,7 @@ public class BatchAsyncProcessor : AbstractAsyncProcessor internal BatchAsyncProcessor(int batchSize, IEnumerable items, Func taskSelector, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateBatchSize(batchSize); + ValidationHelper.ThrowIfNegativeOrZero(batchSize); _batchSize = batchSize; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs index 0dba4f0..bdcf767 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs @@ -1,4 +1,4 @@ -using EnumerableAsyncProcessor.Extensions; +using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -10,7 +10,7 @@ public class RateLimitedParallelAsyncProcessor : AbstractAsyncProcessor internal RateLimitedParallelAsyncProcessor(int count, Func taskSelector, int levelsOfParallelism, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateParallelism(levelsOfParallelism); + ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism); _levelsOfParallelism = levelsOfParallelism; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs index 5478bcc..3b5b263 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs @@ -10,7 +10,7 @@ public class RateLimitedParallelAsyncProcessor : AbstractAsyncProcessor< internal RateLimitedParallelAsyncProcessor(IEnumerable items, Func taskSelector, int levelsOfParallelism, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateParallelism(levelsOfParallelism); + ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism); _levelsOfParallelism = levelsOfParallelism; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessorBase.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessorBase.cs index cd0751a..a073fe5 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessorBase.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessorBase.cs @@ -1,66 +1,31 @@ using System.Runtime.CompilerServices; using EnumerableAsyncProcessor.Interfaces; using EnumerableAsyncProcessor.Extensions; -using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; public abstract class ResultAbstractAsyncProcessorBase : IAsyncProcessor, IAsyncDisposable, IDisposable { - private static readonly TimeSpan DisposalTimeout = TimeSpan.FromSeconds(30); - protected abstract IReadOnlyList> EnumerableTaskCompletionSources { get; } protected readonly CancellationToken CancellationToken; - private readonly CancellationTokenSource _cancellationTokenSource; - private CancellationTokenRegistration _cancellationTokenRegistration; + private readonly ProcessorLifecycle _lifecycle; private Task? _results; - private Task? _processTask; - private volatile bool _disposed; - private readonly object _disposeLock = new(); - private Task Results => _results ??= Task.WhenAll(EnumerableTaskCompletionSources.Select(x => x.Task)); + private Task Results => _results ??= Task.WhenAll(GetEnumerableTasks()); protected ResultAbstractAsyncProcessorBase(CancellationTokenSource cancellationTokenSource) { - ValidationHelper.ValidateCancellationTokenSource(cancellationTokenSource); - - _cancellationTokenSource = cancellationTokenSource; - CancellationToken = cancellationTokenSource.Token; + _lifecycle = new ProcessorLifecycle(cancellationTokenSource, TrySetCanceledAll, TrySetExceptionAll); + CancellationToken = _lifecycle.Token; } internal abstract Task Process(); - // Cancellation is registered here rather than in the constructor so that a token cancelled - // during construction can never invoke CancelAll on a partially constructed instance. - internal void Start() + internal IAsyncProcessor StartProcessing() { - _cancellationTokenRegistration = CancellationToken.Register(CancelAll); - _processTask = RunProcess(); - } - - private async Task RunProcess() - { - try - { - await Process().ConfigureAwait(false); - } - catch (OperationCanceledException) - { - foreach (var taskCompletionSource in EnumerableTaskCompletionSources) - { - taskCompletionSource.TrySetCanceled(CancellationToken); - } - } - catch (Exception exception) - { - // A failure outside the per-item wrappers (e.g. cancellation plumbing) would otherwise - // leave awaiters of the per-item tasks hanging forever. - foreach (var taskCompletionSource in EnumerableTaskCompletionSources) - { - taskCompletionSource.TrySetException(exception); - } - } + _lifecycle.Start(Process); + return this; } public IEnumerable> GetEnumerableTasks() @@ -85,56 +50,28 @@ public TaskAwaiter GetAwaiter() public void CancelAll() { - if (_disposed) - return; - - CancelAllCore(); + _lifecycle.CancelAll(); } - private void CancelAllCore() + private void TrySetCanceledAll() { - if (!_cancellationTokenSource.IsCancellationRequested) - { - _cancellationTokenSource.Cancel(); - } - foreach (var taskCompletionSource in EnumerableTaskCompletionSources) { taskCompletionSource.TrySetCanceled(CancellationToken); } } - public async ValueTask DisposeAsync() + private void TrySetExceptionAll(Exception exception) { - lock (_disposeLock) - { - if (_disposed) - return; - _disposed = true; - } - - // Allow derived classes to dispose their resources first - await DisposeAsyncCore().ConfigureAwait(false); - - CancelAllCore(); - _cancellationTokenRegistration.Dispose(); - - // Give in-flight tasks a bounded window to observe cancellation and finish - if (_processTask is { IsCompleted: false }) + foreach (var taskCompletionSource in EnumerableTaskCompletionSources) { - try - { - using var timeoutCts = new CancellationTokenSource(DisposalTimeout); - await _processTask.WaitAsync(timeoutCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Timed out waiting for in-flight tasks - continue with disposal - } + taskCompletionSource.TrySetException(exception); } + } - _cancellationTokenSource.Dispose(); - + public async ValueTask DisposeAsync() + { + await _lifecycle.DisposeAsync(DisposeAsyncCore).ConfigureAwait(false); GC.SuppressFinalize(this); } @@ -149,19 +86,7 @@ protected virtual ValueTask DisposeAsyncCore() public void Dispose() { - lock (_disposeLock) - { - if (_disposed) - return; - _disposed = true; - } - - // Cancel and release without blocking; in-flight tasks complete against - // already-cancelled completion sources, which is a no-op. - CancelAllCore(); - _cancellationTokenRegistration.Dispose(); - _cancellationTokenSource.Dispose(); - + _lifecycle.Dispose(); GC.SuppressFinalize(this); } } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs index d3cceb9..fe969fd 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs @@ -12,19 +12,13 @@ public abstract class ResultAbstractAsyncProcessor : ResultAbstractAsyn protected ResultAbstractAsyncProcessor(int count, Func> taskSelector, CancellationTokenSource cancellationTokenSource) : base(cancellationTokenSource) { - ValidationHelper.ValidateCount(count); + ValidationHelper.ThrowIfNegative(count); ValidationHelper.ThrowIfNull(taskSelector); - var taskWrappers = new ActionTaskWrapper[count]; - _taskCompletionSources = new TaskCompletionSource[count]; + TaskWrappers = Enumerable.Range(0, count) + .Select(_ => new ActionTaskWrapper(taskSelector, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously))) + .ToArray(); - for (var i = 0; i < count; i++) - { - var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _taskCompletionSources[i] = taskCompletionSource; - taskWrappers[i] = new ActionTaskWrapper(taskSelector, taskCompletionSource); - } - - TaskWrappers = taskWrappers; + _taskCompletionSources = TaskWrappers.Select(x => x.TaskCompletionSource).ToArray(); } } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs index 385263b..f035c4e 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs @@ -1,4 +1,4 @@ -using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; +using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -11,7 +11,7 @@ public class ResultBatchAsyncProcessor : ResultAbstractAsyncProcessor> taskSelector, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateBatchSize(batchSize); + ValidationHelper.ThrowIfNegativeOrZero(batchSize); _batchSize = batchSize; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs index 3b31669..064097c 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs @@ -10,7 +10,7 @@ public class ResultBatchAsyncProcessor : ResultAbstractAsyncPro internal ResultBatchAsyncProcessor(int batchSize, IEnumerable items, Func> taskSelector, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateBatchSize(batchSize); + ValidationHelper.ThrowIfNegativeOrZero(batchSize); _batchSize = batchSize; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs index f3e4c87..5128ee1 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs @@ -1,4 +1,4 @@ - + using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -11,7 +11,7 @@ public class ResultRateLimitedParallelAsyncProcessor : ResultAbstractAs internal ResultRateLimitedParallelAsyncProcessor(int count, Func> taskSelector, int levelsOfParallelism, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateParallelism(levelsOfParallelism); + ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism); _levelsOfParallelism = levelsOfParallelism; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs index d67d9ff..750eb67 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs @@ -10,7 +10,7 @@ public class ResultRateLimitedParallelAsyncProcessor : ResultAb internal ResultRateLimitedParallelAsyncProcessor(IEnumerable items, Func> taskSelector, int levelsOfParallelism, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateParallelism(levelsOfParallelism); + ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism); _levelsOfParallelism = levelsOfParallelism; } diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs index f43b9b3..cfcc756 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs @@ -1,4 +1,4 @@ -using EnumerableAsyncProcessor.Extensions; +using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -11,8 +11,8 @@ public class ResultTimedRateLimitedParallelAsyncProcessor : ResultAbstr internal ResultTimedRateLimitedParallelAsyncProcessor(int count, Func> taskSelector, int levelsOfParallelism, TimeSpan timeSpan, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateParallelism(levelsOfParallelism); - ValidationHelper.ValidateTimeSpan(timeSpan); + ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism); + ValidationHelper.ThrowIfNegative(timeSpan); _levelsOfParallelism = levelsOfParallelism; _timeSpan = timeSpan; diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs index 18be667..a6fbf42 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs @@ -11,8 +11,8 @@ public class ResultTimedRateLimitedParallelAsyncProcessor : Res internal ResultTimedRateLimitedParallelAsyncProcessor(IEnumerable items, Func> taskSelector, int levelsOfParallelism, TimeSpan timeSpan, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateParallelism(levelsOfParallelism); - ValidationHelper.ValidateTimeSpan(timeSpan); + ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism); + ValidationHelper.ThrowIfNegative(timeSpan); _levelsOfParallelism = levelsOfParallelism; _timeSpan = timeSpan; diff --git a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs index b8b3285..a5e8862 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs @@ -1,4 +1,4 @@ -using EnumerableAsyncProcessor.Extensions; +using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -11,8 +11,8 @@ public class TimedRateLimitedParallelAsyncProcessor : AbstractAsyncProcessor internal TimedRateLimitedParallelAsyncProcessor(int count, Func taskSelector, int levelsOfParallelism, TimeSpan timeSpan, CancellationTokenSource cancellationTokenSource) : base(count, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateParallelism(levelsOfParallelism); - ValidationHelper.ValidateTimeSpan(timeSpan); + ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism); + ValidationHelper.ThrowIfNegative(timeSpan); _levelsOfParallelism = levelsOfParallelism; _timeSpan = timeSpan; diff --git a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs index c8eb84b..2be1bf3 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs @@ -11,8 +11,8 @@ public class TimedRateLimitedParallelAsyncProcessor : AbstractAsyncProce internal TimedRateLimitedParallelAsyncProcessor(IEnumerable items, Func taskSelector, int levelsOfParallelism, TimeSpan timeSpan, CancellationTokenSource cancellationTokenSource) : base(items, taskSelector, cancellationTokenSource) { - ValidationHelper.ValidateParallelism(levelsOfParallelism); - ValidationHelper.ValidateTimeSpan(timeSpan); + ValidationHelper.ThrowIfNegativeOrZero(levelsOfParallelism); + ValidationHelper.ThrowIfNegative(timeSpan); _levelsOfParallelism = levelsOfParallelism; _timeSpan = timeSpan; diff --git a/EnumerableAsyncProcessor/TaskWrapper.cs b/EnumerableAsyncProcessor/TaskWrapper.cs index 7c0d16a..d286295 100644 --- a/EnumerableAsyncProcessor/TaskWrapper.cs +++ b/EnumerableAsyncProcessor/TaskWrapper.cs @@ -1,5 +1,46 @@ namespace EnumerableAsyncProcessor; +/// +/// Completes a completion source from a failed task-factory invocation, classifying the failure +/// by the state of the task rather than the caught exception. +/// +internal static class TaskCompletionSourceExtensions +{ + internal static void TrySetFromFault(this TaskCompletionSource taskCompletionSource, Task? task, Exception exception, CancellationToken cancellationToken) + { + if (task is { IsCanceled: true }) + { + taskCompletionSource.TrySetCanceled(cancellationToken); + } + else if (task is { IsFaulted: true }) + { + // Preserve every failure from the task, not just the first + taskCompletionSource.TrySetException(task.Exception!.InnerExceptions); + } + else + { + taskCompletionSource.TrySetException(exception); + } + } + + internal static void TrySetFromFault(this TaskCompletionSource taskCompletionSource, Task? task, Exception exception, CancellationToken cancellationToken) + { + if (task is { IsCanceled: true }) + { + taskCompletionSource.TrySetCanceled(cancellationToken); + } + else if (task is { IsFaulted: true }) + { + // Preserve every failure from the task, not just the first + taskCompletionSource.TrySetException(task.Exception!.InnerExceptions); + } + else + { + taskCompletionSource.TrySetException(exception); + } + } +} + /// /// A struct wrapper pairing an action task factory with its completion source. /// @@ -31,19 +72,7 @@ public async Task Process(CancellationToken cancellationToken) } catch (Exception e) { - if (task is { IsCanceled: true }) - { - TaskCompletionSource.TrySetCanceled(cancellationToken); - } - else if (task is { IsFaulted: true }) - { - // Preserve every failure from the task, not just the first - TaskCompletionSource.TrySetException(task.Exception!.InnerExceptions); - } - else - { - TaskCompletionSource.TrySetException(e); - } + TaskCompletionSource.TrySetFromFault(task, e, cancellationToken); } } } @@ -81,19 +110,7 @@ public async Task Process(CancellationToken cancellationToken) } catch (Exception e) { - if (task is { IsCanceled: true }) - { - TaskCompletionSource.TrySetCanceled(cancellationToken); - } - else if (task is { IsFaulted: true }) - { - // Preserve every failure from the task, not just the first - TaskCompletionSource.TrySetException(task.Exception!.InnerExceptions); - } - else - { - TaskCompletionSource.TrySetException(e); - } + TaskCompletionSource.TrySetFromFault(task, e, cancellationToken); } } } @@ -130,19 +147,7 @@ public async Task Process(CancellationToken cancellationToken) } catch (Exception e) { - if (task is { IsCanceled: true }) - { - TaskCompletionSource.TrySetCanceled(cancellationToken); - } - else if (task is { IsFaulted: true }) - { - // Preserve every failure from the task, not just the first - TaskCompletionSource.TrySetException(task.Exception!.InnerExceptions); - } - else - { - TaskCompletionSource.TrySetException(e); - } + TaskCompletionSource.TrySetFromFault(task, e, cancellationToken); } } } @@ -177,19 +182,7 @@ public async Task Process(CancellationToken cancellationToken) } catch (Exception e) { - if (task is { IsCanceled: true }) - { - TaskCompletionSource.TrySetCanceled(cancellationToken); - } - else if (task is { IsFaulted: true }) - { - // Preserve every failure from the task, not just the first - TaskCompletionSource.TrySetException(task.Exception!.InnerExceptions); - } - else - { - TaskCompletionSource.TrySetException(e); - } + TaskCompletionSource.TrySetFromFault(task, e, cancellationToken); } } } diff --git a/EnumerableAsyncProcessor/Validation/ValidationHelper.cs b/EnumerableAsyncProcessor/Validation/ValidationHelper.cs index 174ace6..fbcfef6 100644 --- a/EnumerableAsyncProcessor/Validation/ValidationHelper.cs +++ b/EnumerableAsyncProcessor/Validation/ValidationHelper.cs @@ -77,50 +77,6 @@ public static void ThrowIfNegative(TimeSpan value, [CallerArgumentExpression(nam } } - /// - /// Validates that a count of executions is valid. - /// - /// The count value to validate. - /// The parameter name for the exception. - /// Thrown when the count is negative. - public static void ValidateCount(int count, [CallerArgumentExpression(nameof(count))] string? paramName = null) - { - ThrowIfNegative(count, paramName); - } - - /// - /// Validates that a batch size is positive. - /// - /// The batch size to validate. - /// The parameter name for the exception. - /// Thrown when the batch size is negative or zero. - public static void ValidateBatchSize(int batchSize, [CallerArgumentExpression(nameof(batchSize))] string? paramName = null) - { - ThrowIfNegativeOrZero(batchSize, paramName); - } - - /// - /// Validates that a parallelism level is positive. - /// - /// The parallelism level to validate. - /// The parameter name for the exception. - /// Thrown when the parallelism level is negative or zero. - public static void ValidateParallelism(int levelsOfParallelism, [CallerArgumentExpression(nameof(levelsOfParallelism))] string? paramName = null) - { - ThrowIfNegativeOrZero(levelsOfParallelism, paramName); - } - - /// - /// Validates that a TimeSpan is valid for timed operations. - /// - /// The TimeSpan to validate. - /// The parameter name for the exception. - /// Thrown when the TimeSpan is negative. - public static void ValidateTimeSpan(TimeSpan timeSpan, [CallerArgumentExpression(nameof(timeSpan))] string? paramName = null) - { - ThrowIfNegative(timeSpan, paramName); - } - /// /// Validates that a CancellationTokenSource is not null and not already cancelled. /// From d494f49c09c720ac180dd9b0bdf2f04162d13990 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:03:23 +0100 Subject: [PATCH 04/11] perf: worker-pool processing, array hot paths, O(N) streaming Measured at 100k items with completed-task selectors (pure overhead, net9): - unbounded parallel: 61ms -> 16ms, 444 -> 162 B/item - maxConcurrency 64: 57ms -> 10ms, 431 -> 164 B/item - rate-limited 64: 163ms -> 9ms, 1062 -> 156 B/item Changes: - Rate-limited, timed and throttled parallel processors now run on a fixed pool of P worker loops (WorkerPool) instead of queueing one task per item: P Task.Run calls and one Interlocked increment per item replace N Task.Run tasks, N closures and N semaphore waits. The internal ITaskWrapper interface lets one generic helper serve all four wrapper structs via constrained calls without boxing. - TaskWrappers fields are typed as arrays so iteration uses struct enumerators and LINQ array fast paths instead of interface-dispatched enumerators. - ToIAsyncEnumerable pre-net9 fallback replaced the O(N^2) WhenAny loop (rebuilding the task list every completion) with completion-order buckets: O(N), verified on net8 - 100k results stream in 73ms. - Throttled ProcessInParallel now validates maxConcurrency at build time; previously 0 surfaced as a semaphore ArgumentOutOfRangeException at process time. --- .../Extensions/EnumerableExtensions.cs | 49 ++++++--------- .../Abstract/AbstractAsyncProcessor.cs | 2 +- .../Abstract/AbstractAsyncProcessor_1.cs | 2 +- .../ParallelAsyncProcessor.cs | 44 +++---------- .../ParallelAsyncProcessor_1.cs | 44 +++---------- .../RateLimitedParallelAsyncProcessor.cs | 6 +- .../RateLimitedParallelAsyncProcessor_1.cs | 6 +- .../ResultAbstractAsyncProcessor_1.cs | 2 +- .../ResultAbstractAsyncProcessor_2.cs | 2 +- .../ResultParallelAsyncProcessor_1.cs | 44 +++---------- .../ResultParallelAsyncProcessor_2.cs | 44 +++---------- ...sultRateLimitedParallelAsyncProcessor_1.cs | 6 +- ...sultRateLimitedParallelAsyncProcessor_2.cs | 6 +- ...imedRateLimitedParallelAsyncProcessor_1.cs | 9 +-- ...imedRateLimitedParallelAsyncProcessor_2.cs | 9 +-- .../TimedRateLimitedParallelAsyncProcessor.cs | 9 +-- ...imedRateLimitedParallelAsyncProcessor_1.cs | 9 +-- EnumerableAsyncProcessor/TaskWrapper.cs | 17 +++-- EnumerableAsyncProcessor/WorkerPool.cs | 63 +++++++++++++++++++ 19 files changed, 148 insertions(+), 225 deletions(-) create mode 100644 EnumerableAsyncProcessor/WorkerPool.cs diff --git a/EnumerableAsyncProcessor/Extensions/EnumerableExtensions.cs b/EnumerableAsyncProcessor/Extensions/EnumerableExtensions.cs index 7e0a438..b79d32d 100644 --- a/EnumerableAsyncProcessor/Extensions/EnumerableExtensions.cs +++ b/EnumerableAsyncProcessor/Extensions/EnumerableExtensions.cs @@ -140,38 +140,29 @@ internal static async IAsyncEnumerable ToIAsyncEnumerable(this IEnumerable yield return task.Result; } #else - var managedTasksList = tasks.ToList(); + // Interleaving via completion-order buckets: each task's continuation claims the next + // bucket, so streaming N tasks is O(N) rather than the O(N^2) of a WhenAny loop. + var inputTasks = tasks.ToList(); - // Create a cancellation task that will complete when cancellation is requested - using var cancellationTcs = new CancellationTokenSource(); - var cancellationTask = Task.Delay(Timeout.Infinite, cancellationTcs.Token); - - // Register callback to trigger the cancellation task - using var registration = cancellationToken.Register(() => cancellationTcs.Cancel()); + var buckets = new TaskCompletionSource>[inputTasks.Count]; + for (var i = 0; i < buckets.Length; i++) + { + buckets[i] = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + } - while (managedTasksList.Count != 0) + var nextBucketIndex = -1; + foreach (var task in inputTasks) { - // Check for cancellation before each iteration - cancellationToken.ThrowIfCancellationRequested(); - - // Include the cancellation task in WhenAny - var allTasks = new List(managedTasksList.Count + 1); - allTasks.AddRange(managedTasksList); - allTasks.Add(cancellationTask); - - var finishedTask = await Task.WhenAny(allTasks).ConfigureAwait(false); - - // If the cancellation task completed, throw cancellation - if (finishedTask == cancellationTask) - { - cancellationToken.ThrowIfCancellationRequested(); - // This should not happen as cancellation should throw above, but as a safety measure: - throw new OperationCanceledException(cancellationToken); - } - - // Remove and yield the completed task - var completedTask = (Task)finishedTask; - managedTasksList.Remove(completedTask); + _ = task.ContinueWith( + completedTask => buckets[Interlocked.Increment(ref nextBucketIndex)].TrySetResult(completedTask), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + foreach (var bucket in buckets) + { + var completedTask = await bucket.Task.WaitAsync(cancellationToken).ConfigureAwait(false); yield return await completedTask.ConfigureAwait(false); } #endif diff --git a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs index b770649..88ef7b1 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor.cs @@ -4,7 +4,7 @@ namespace EnumerableAsyncProcessor.RunnableProcessors.Abstract; public abstract class AbstractAsyncProcessor : AbstractAsyncProcessorBase { - protected readonly IReadOnlyList TaskWrappers; + protected readonly ActionTaskWrapper[] TaskWrappers; private readonly TaskCompletionSource[] _taskCompletionSources; diff --git a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor_1.cs index 089ecc1..6b493b7 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/Abstract/AbstractAsyncProcessor_1.cs @@ -4,7 +4,7 @@ namespace EnumerableAsyncProcessor.RunnableProcessors.Abstract; public abstract class AbstractAsyncProcessor : AbstractAsyncProcessorBase { - protected readonly IReadOnlyList> TaskWrappers; + protected readonly ItemTaskWrapper[] TaskWrappers; private readonly TaskCompletionSource[] _taskCompletionSources; diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor.cs index 141223c..a4712b6 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor.cs @@ -1,4 +1,5 @@ using EnumerableAsyncProcessor.RunnableProcessors.Abstract; +using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors; @@ -6,9 +7,14 @@ public class ParallelAsyncProcessor : AbstractAsyncProcessor { private readonly int? _maxConcurrency; private readonly bool _scheduleOnThreadPool; - + internal ParallelAsyncProcessor(int count, Func taskSelector, CancellationTokenSource cancellationTokenSource, int? maxConcurrency = null, bool scheduleOnThreadPool = false) : base(count, taskSelector, cancellationTokenSource) { + if (maxConcurrency is { } concurrencyLimit) + { + ValidationHelper.ThrowIfNegativeOrZero(concurrencyLimit, nameof(maxConcurrency)); + } + _maxConcurrency = maxConcurrency; _scheduleOnThreadPool = scheduleOnThreadPool; } @@ -38,38 +44,8 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper => return; } - // Use semaphore for concurrency throttling - using var semaphore = new SemaphoreSlim(_maxConcurrency.Value, _maxConcurrency.Value); - - // Materialize tasks immediately to ensure they all start in parallel (up to concurrency limit) - var tasks = _scheduleOnThreadPool - ? // Use Task.Run to prevent synchronous code from blocking thread pool threads - TaskWrappers.Select(taskWrapper => Task.Run(async () => - { - await semaphore.WaitAsync(CancellationToken).ConfigureAwait(false); - try - { - await taskWrapper.Process(CancellationToken).ConfigureAwait(false); - } - finally - { - semaphore.Release(); - } - }, CancellationToken)).ToList() - : // Direct execution for maximum performance - TaskWrappers.Select(async taskWrapper => - { - await semaphore.WaitAsync(CancellationToken).ConfigureAwait(false); - try - { - await taskWrapper.Process(CancellationToken).ConfigureAwait(false); - } - finally - { - semaphore.Release(); - } - }).ToList(); // Force immediate task creation - - await Task.WhenAll(tasks).ConfigureAwait(false); + // 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); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor_1.cs index 116b074..ec976ca 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor_1.cs @@ -1,4 +1,5 @@ using EnumerableAsyncProcessor.RunnableProcessors.Abstract; +using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors; @@ -6,9 +7,14 @@ public class ParallelAsyncProcessor : AbstractAsyncProcessor { private readonly int? _maxConcurrency; private readonly bool _scheduleOnThreadPool; - + internal ParallelAsyncProcessor(IEnumerable items, Func taskSelector, CancellationTokenSource cancellationTokenSource, int? maxConcurrency = null, bool scheduleOnThreadPool = false) : base(items, taskSelector, cancellationTokenSource) { + if (maxConcurrency is { } concurrencyLimit) + { + ValidationHelper.ThrowIfNegativeOrZero(concurrencyLimit, nameof(maxConcurrency)); + } + _maxConcurrency = maxConcurrency; _scheduleOnThreadPool = scheduleOnThreadPool; } @@ -38,38 +44,8 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper => return; } - // Use semaphore for concurrency throttling - using var semaphore = new SemaphoreSlim(_maxConcurrency.Value, _maxConcurrency.Value); - - // Materialize tasks immediately to ensure they all start in parallel (up to concurrency limit) - var tasks = _scheduleOnThreadPool - ? // Use Task.Run to prevent synchronous code from blocking thread pool threads - TaskWrappers.Select(taskWrapper => Task.Run(async () => - { - await semaphore.WaitAsync(CancellationToken).ConfigureAwait(false); - try - { - await taskWrapper.Process(CancellationToken).ConfigureAwait(false); - } - finally - { - semaphore.Release(); - } - }, CancellationToken)).ToList() - : // Direct execution for maximum performance - TaskWrappers.Select(async taskWrapper => - { - await semaphore.WaitAsync(CancellationToken).ConfigureAwait(false); - try - { - await taskWrapper.Process(CancellationToken).ConfigureAwait(false); - } - finally - { - semaphore.Release(); - } - }).ToList(); // Force immediate task creation - - await Task.WhenAll(tasks).ConfigureAwait(false); + // 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); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs index bdcf767..44cbcac 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs @@ -1,4 +1,3 @@ -using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -17,9 +16,6 @@ internal RateLimitedParallelAsyncProcessor(int count, Func taskSelector, i internal override Task Process() { - // Task.Run guards the shared worker slots against synchronous code in user delegates - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - taskWrapper => Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), - CancellationToken); + return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: null, CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs index 3b5b263..f4ec2d5 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs @@ -1,4 +1,3 @@ -using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -17,9 +16,6 @@ internal RateLimitedParallelAsyncProcessor(IEnumerable items, Func Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), - CancellationToken); + return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: null, CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs index fe969fd..85ff76b 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_1.cs @@ -4,7 +4,7 @@ namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; public abstract class ResultAbstractAsyncProcessor : ResultAbstractAsyncProcessorBase { - protected readonly IReadOnlyList> TaskWrappers; + protected readonly ActionTaskWrapper[] TaskWrappers; private readonly TaskCompletionSource[] _taskCompletionSources; diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_2.cs index d9110e4..615b5f9 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/Abstract/ResultAbstractAsyncProcessor_2.cs @@ -4,7 +4,7 @@ namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; public abstract class ResultAbstractAsyncProcessor : ResultAbstractAsyncProcessorBase { - protected readonly IReadOnlyList> TaskWrappers; + protected readonly ItemTaskWrapper[] TaskWrappers; private readonly TaskCompletionSource[] _taskCompletionSources; diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_1.cs index 8a9420e..bc509da 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_1.cs @@ -1,4 +1,5 @@ using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; +using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors; @@ -6,9 +7,14 @@ public class ResultParallelAsyncProcessor : ResultAbstractAsyncProcesso { private readonly int? _maxConcurrency; private readonly bool _scheduleOnThreadPool; - + internal ResultParallelAsyncProcessor(int count, Func> taskSelector, CancellationTokenSource cancellationTokenSource, int? maxConcurrency = null, bool scheduleOnThreadPool = false) : base(count, taskSelector, cancellationTokenSource) { + if (maxConcurrency is { } concurrencyLimit) + { + ValidationHelper.ThrowIfNegativeOrZero(concurrencyLimit, nameof(maxConcurrency)); + } + _maxConcurrency = maxConcurrency; _scheduleOnThreadPool = scheduleOnThreadPool; } @@ -38,38 +44,8 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper => return; } - // Use semaphore for concurrency throttling - using var semaphore = new SemaphoreSlim(_maxConcurrency.Value, _maxConcurrency.Value); - - // Materialize tasks immediately to ensure they all start in parallel (up to concurrency limit) - var tasks = _scheduleOnThreadPool - ? // Use Task.Run to prevent synchronous code from blocking thread pool threads - TaskWrappers.Select(taskWrapper => Task.Run(async () => - { - await semaphore.WaitAsync(CancellationToken).ConfigureAwait(false); - try - { - await taskWrapper.Process(CancellationToken).ConfigureAwait(false); - } - finally - { - semaphore.Release(); - } - }, CancellationToken)).ToList() - : // Direct execution for maximum performance - TaskWrappers.Select(async taskWrapper => - { - await semaphore.WaitAsync(CancellationToken).ConfigureAwait(false); - try - { - await taskWrapper.Process(CancellationToken).ConfigureAwait(false); - } - finally - { - semaphore.Release(); - } - }).ToList(); // Force immediate task creation - - await Task.WhenAll(tasks).ConfigureAwait(false); + // 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); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_2.cs index ea4dae0..fa36303 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_2.cs @@ -1,4 +1,5 @@ using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; +using EnumerableAsyncProcessor.Validation; namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors; @@ -6,9 +7,14 @@ public class ResultParallelAsyncProcessor : ResultAbstractAsync { private readonly int? _maxConcurrency; private readonly bool _scheduleOnThreadPool; - + internal ResultParallelAsyncProcessor(IEnumerable items, Func> taskSelector, CancellationTokenSource cancellationTokenSource, int? maxConcurrency = null, bool scheduleOnThreadPool = false) : base(items, taskSelector, cancellationTokenSource) { + if (maxConcurrency is { } concurrencyLimit) + { + ValidationHelper.ThrowIfNegativeOrZero(concurrencyLimit, nameof(maxConcurrency)); + } + _maxConcurrency = maxConcurrency; _scheduleOnThreadPool = scheduleOnThreadPool; } @@ -38,38 +44,8 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper => return; } - // Use semaphore for concurrency throttling - using var semaphore = new SemaphoreSlim(_maxConcurrency.Value, _maxConcurrency.Value); - - // Materialize tasks immediately to ensure they all start in parallel (up to concurrency limit) - var tasks = _scheduleOnThreadPool - ? // Use Task.Run to prevent synchronous code from blocking thread pool threads - TaskWrappers.Select(taskWrapper => Task.Run(async () => - { - await semaphore.WaitAsync(CancellationToken).ConfigureAwait(false); - try - { - await taskWrapper.Process(CancellationToken).ConfigureAwait(false); - } - finally - { - semaphore.Release(); - } - }, CancellationToken)).ToList() - : // Direct execution for maximum performance - TaskWrappers.Select(async taskWrapper => - { - await semaphore.WaitAsync(CancellationToken).ConfigureAwait(false); - try - { - await taskWrapper.Process(CancellationToken).ConfigureAwait(false); - } - finally - { - semaphore.Release(); - } - }).ToList(); // Force immediate task creation - - await Task.WhenAll(tasks).ConfigureAwait(false); + // 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); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs index 5128ee1..e3abbfe 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs @@ -1,5 +1,4 @@ -using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -18,9 +17,6 @@ internal ResultRateLimitedParallelAsyncProcessor(int count, Func> internal override Task Process() { - // Task.Run guards the shared worker slots against synchronous code in user delegates - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - taskWrapper => Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), - CancellationToken); + return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: null, CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs index 750eb67..752fef4 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs @@ -1,4 +1,3 @@ -using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -17,9 +16,6 @@ internal ResultRateLimitedParallelAsyncProcessor(IEnumerable items, Func internal override Task Process() { - // Task.Run guards the shared worker slots against synchronous code in user delegates - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - taskWrapper => Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), - CancellationToken); + return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: null, CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs index cfcc756..a984106 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs @@ -1,4 +1,3 @@ -using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -20,12 +19,6 @@ internal ResultTimedRateLimitedParallelAsyncProcessor(int count, Func Task.WhenAll( - Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), - Task.Delay(_timeSpan, CancellationToken)), - CancellationToken); + return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs index a6fbf42..ccc9a1a 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs @@ -1,4 +1,3 @@ -using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -20,12 +19,6 @@ internal ResultTimedRateLimitedParallelAsyncProcessor(IEnumerable items, internal override Task Process() { - // Each worker slot holds an item for at least _timeSpan to honour the rate limit. - // Task.Run guards the shared worker slots against synchronous code in user delegates - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - taskWrapper => Task.WhenAll( - Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), - Task.Delay(_timeSpan, CancellationToken)), - CancellationToken); + return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs index a5e8862..a312168 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs @@ -1,4 +1,3 @@ -using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -20,12 +19,6 @@ internal TimedRateLimitedParallelAsyncProcessor(int count, Func taskSelect internal override Task Process() { - // Each worker slot holds an item for at least _timeSpan to honour the rate limit. - // Task.Run guards the shared worker slots against synchronous code in user delegates - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - taskWrapper => Task.WhenAll( - Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), - Task.Delay(_timeSpan, CancellationToken)), - CancellationToken); + return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs index 2be1bf3..a595f7c 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs @@ -1,4 +1,3 @@ -using EnumerableAsyncProcessor.Extensions; using EnumerableAsyncProcessor.RunnableProcessors.Abstract; using EnumerableAsyncProcessor.Validation; @@ -20,12 +19,6 @@ internal TimedRateLimitedParallelAsyncProcessor(IEnumerable items, Func< internal override Task Process() { - // Each worker slot holds an item for at least _timeSpan to honour the rate limit. - // Task.Run guards the shared worker slots against synchronous code in user delegates - return TaskWrappers.InParallelAsync(_levelsOfParallelism, - taskWrapper => Task.WhenAll( - Task.Run(() => taskWrapper.Process(CancellationToken), CancellationToken), - Task.Delay(_timeSpan, CancellationToken)), - CancellationToken); + return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken); } } \ No newline at end of file diff --git a/EnumerableAsyncProcessor/TaskWrapper.cs b/EnumerableAsyncProcessor/TaskWrapper.cs index d286295..0b3acd4 100644 --- a/EnumerableAsyncProcessor/TaskWrapper.cs +++ b/EnumerableAsyncProcessor/TaskWrapper.cs @@ -1,5 +1,14 @@ namespace EnumerableAsyncProcessor; +/// +/// Allows the worker-pool helpers to process any wrapper struct through a constrained +/// generic type parameter without boxing. +/// +internal interface ITaskWrapper +{ + Task Process(CancellationToken cancellationToken); +} + /// /// Completes a completion source from a failed task-factory invocation, classifying the failure /// by the state of the task rather than the caught exception. @@ -44,7 +53,7 @@ internal static void TrySetFromFault(this TaskCompletionSource /// /// A struct wrapper pairing an action task factory with its completion source. /// -public readonly struct ActionTaskWrapper +public readonly struct ActionTaskWrapper : ITaskWrapper { public readonly Func TaskFactory; public readonly TaskCompletionSource TaskCompletionSource; @@ -80,7 +89,7 @@ public async Task Process(CancellationToken cancellationToken) /// /// A struct wrapper pairing an input item and its task factory with a completion source. /// -public readonly struct ItemTaskWrapper +public readonly struct ItemTaskWrapper : ITaskWrapper { public readonly TInput Input; public readonly Func TaskFactory; @@ -118,7 +127,7 @@ public async Task Process(CancellationToken cancellationToken) /// /// A struct wrapper pairing an input item and its result-producing task factory with a completion source. /// -public readonly struct ItemTaskWrapper +public readonly struct ItemTaskWrapper : ITaskWrapper { public readonly TInput Input; public readonly Func> TaskFactory; @@ -155,7 +164,7 @@ public async Task Process(CancellationToken cancellationToken) /// /// A struct wrapper pairing a result-producing task factory with its completion source. /// -public readonly struct ActionTaskWrapper +public readonly struct ActionTaskWrapper : ITaskWrapper { public readonly Func> TaskFactory; public readonly TaskCompletionSource TaskCompletionSource; diff --git a/EnumerableAsyncProcessor/WorkerPool.cs b/EnumerableAsyncProcessor/WorkerPool.cs new file mode 100644 index 0000000..04691bd --- /dev/null +++ b/EnumerableAsyncProcessor/WorkerPool.cs @@ -0,0 +1,63 @@ +namespace EnumerableAsyncProcessor; + +/// +/// Runs task wrappers on a fixed pool of worker loops instead of queueing one throttled task per item. +/// For N items and P workers this costs P Task.Run tasks and one Interlocked increment per item, +/// rather than N Task.Run tasks, N closures and N semaphore waits. +/// +internal static class WorkerPool +{ + /// + /// When set, each worker holds its slot for at least this long per item, which caps throughput + /// at (workerCount / minimumIterationTime) operations for the timed rate-limited processors. + /// + internal static Task ProcessAsync( + TWrapper[] taskWrappers, + int workerCount, + TimeSpan? minimumIterationTime, + CancellationToken cancellationToken) where TWrapper : ITaskWrapper + { + workerCount = Math.Min(workerCount, taskWrappers.Length); + + if (workerCount == 0) + { + return Task.CompletedTask; + } + + var nextIndex = -1; + + var workers = new Task[workerCount]; + + for (var i = 0; i < workerCount; i++) + { + // Task.Run guards the worker slots against synchronous code in user delegates + workers[i] = Task.Run(async () => + { + while (true) + { + var index = Interlocked.Increment(ref nextIndex); + + if (index >= taskWrappers.Length) + { + return; + } + + // Process never throws; it completes the item's TaskCompletionSource instead, + // so one failed item cannot stop the worker from draining the rest. + var processTask = taskWrappers[index].Process(cancellationToken); + + if (minimumIterationTime is { } minimumTime) + { + await Task.WhenAll(processTask, Task.Delay(minimumTime, cancellationToken)).ConfigureAwait(false); + } + else + { + await processTask.ConfigureAwait(false); + } + } + }, cancellationToken); + } + + return Task.WhenAll(workers); + } +} From 36d38b0c932779b7803aa4fd1d0722aabb043619 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:12:21 +0100 Subject: [PATCH 05/11] test: regression coverage for correctness and perf fixes 30 new tests across five suites, each pinned to a previously shipped bug: - InputEnumerationRegressionTests: input enumerated exactly once across process/await/CancelAll/dispose; one-shot enumerables work; iterator exceptions throw at build time instead of hanging awaiters. - DisposalRegressionTests: DisposeAsync actually cancels pending tasks and completes promptly; synchronous Dispose no longer blocks on in-flight work; CancelAll on result processors returns immediately (previously blocked up to 30s via the Dispose cancellation callback); disposal is idempotent in any order. - ExceptionFidelityTests: multi-fault tasks preserve every inner exception (GetBaseException previously kept one); one failing item cannot stop the rest; completion sources run continuations asynchronously. - ValidationRegressionTests: zero/negative parallelism, maxConcurrency, batch size and negative timespans throw at build time on void AND result variants (result variants previously skipped validation; maxConcurrency 0 previously failed mid-processing); the removed 10k caps stay removed; already-cancelled tokens fail cleanly, never NRE. - WorkerPoolBehaviourTests: concurrency limit holds on the worker-pool path, every item processed exactly once, oversized limits fine, cancellation drains promptly, result order preserved regardless of completion order. - StreamingResultsTests: results stream in completion order, exactly once, and honour cancellation. Test project now targets net9.0 and net8.0 so the pre-net9 completion-order bucket implementation of ToIAsyncEnumerable is exercised in CI rather than only compiled. --- .../DisposalRegressionTests.cs | 130 +++++++++++++++++ .../EnumerableAsyncProcessor.UnitTests.csproj | 3 +- .../ExceptionFidelityTests.cs | 115 +++++++++++++++ .../InputEnumerationRegressionTests.cs | 136 +++++++++++++++++ .../StreamingResultsTests.cs | 111 ++++++++++++++ .../ValidationRegressionTests.cs | 125 ++++++++++++++++ .../WorkerPoolBehaviourTests.cs | 137 ++++++++++++++++++ 7 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 EnumerableAsyncProcessor.UnitTests/DisposalRegressionTests.cs create mode 100644 EnumerableAsyncProcessor.UnitTests/ExceptionFidelityTests.cs create mode 100644 EnumerableAsyncProcessor.UnitTests/InputEnumerationRegressionTests.cs create mode 100644 EnumerableAsyncProcessor.UnitTests/StreamingResultsTests.cs create mode 100644 EnumerableAsyncProcessor.UnitTests/ValidationRegressionTests.cs create mode 100644 EnumerableAsyncProcessor.UnitTests/WorkerPoolBehaviourTests.cs diff --git a/EnumerableAsyncProcessor.UnitTests/DisposalRegressionTests.cs b/EnumerableAsyncProcessor.UnitTests/DisposalRegressionTests.cs new file mode 100644 index 0000000..1683625 --- /dev/null +++ b/EnumerableAsyncProcessor.UnitTests/DisposalRegressionTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using EnumerableAsyncProcessor.Extensions; + +namespace EnumerableAsyncProcessor.UnitTests; + +/// +/// Guards the disposal and cancellation semantics: +/// - disposal must actually cancel pending work (a guard-ordering bug previously made it a no-op), +/// - synchronous Dispose must not block (it previously ran sync-over-async with a 30s wait), +/// - CancelAll on a result processor must not block the calling thread (it previously invoked +/// blocking Dispose via the cancellation callback). +/// +public class DisposalRegressionTests +{ + [Test] + public async Task DisposeAsync_Cancels_Unstarted_Tasks_And_Completes_Promptly() + { + var blocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstItemStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var processor = Enumerable.Range(0, 10).ToList() + .ForEachAsync(async _ => + { + firstItemStarted.TrySetResult(); + await blocker.Task; + }) + .ProcessInParallel(1); + + await firstItemStarted.Task; + + var stopwatch = Stopwatch.StartNew(); + var disposeTask = processor.DisposeAsync(); + + // Cancellation happens synchronously inside DisposeAsync, before it waits for in-flight work. + // Previously nothing was cancelled and disposal just waited for tasks to finish naturally. + await Assert.That(processor.GetEnumerableTasks().Count(x => x.IsCanceled)).IsEqualTo(10); + + blocker.TrySetResult(); + await disposeTask; + stopwatch.Stop(); + + await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(5)); + } + + [Test] + public async Task Synchronous_Dispose_Returns_Without_Blocking_On_InFlight_Work() + { + var blocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstItemStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var processor = Enumerable.Range(0, 10).ToList() + .ForEachAsync(async _ => + { + firstItemStarted.TrySetResult(); + await blocker.Task; + }) + .ProcessInParallel(1); + + await firstItemStarted.Task; + + try + { + var stopwatch = Stopwatch.StartNew(); + processor.Dispose(); + stopwatch.Stop(); + + await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(2)); + await Assert.That(processor.GetEnumerableTasks().Count(x => x.IsCanceled)).IsEqualTo(10); + } + finally + { + blocker.TrySetResult(); + } + } + + [Test] + public async Task CancelAll_On_Result_Processor_Does_Not_Block_And_Cancels_Pending_Tasks() + { + var blocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstItemStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var processor = Enumerable.Range(0, 10).ToList() + .SelectAsync(async i => + { + firstItemStarted.TrySetResult(); + await blocker.Task; + return i; + }) + .ProcessInParallel(1); + + await firstItemStarted.Task; + + try + { + var stopwatch = Stopwatch.StartNew(); + processor.CancelAll(); + stopwatch.Stop(); + + await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(2)); + await Assert.That(processor.GetEnumerableTasks().Count(x => x.IsCanceled)).IsEqualTo(10); + await Assert.ThrowsAsync(() => processor.GetResultsAsync()); + } + finally + { + blocker.TrySetResult(); + } + + await processor.DisposeAsync(); + } + + [Test] + public async Task Disposal_Is_Idempotent_And_Safe_In_Any_Order() + { + var processor = Enumerable.Range(0, 5).ToList() + .ForEachAsync(_ => Task.CompletedTask) + .ProcessInParallel(); + + await processor.WaitAsync(); + + await processor.DisposeAsync(); + await processor.DisposeAsync(); + processor.Dispose(); + processor.CancelAll(); + + await Assert.That(processor.GetEnumerableTasks().Count(x => x.IsCompletedSuccessfully)).IsEqualTo(5); + } +} diff --git a/EnumerableAsyncProcessor.UnitTests/EnumerableAsyncProcessor.UnitTests.csproj b/EnumerableAsyncProcessor.UnitTests/EnumerableAsyncProcessor.UnitTests.csproj index ddd2fda..803ecfb 100644 --- a/EnumerableAsyncProcessor.UnitTests/EnumerableAsyncProcessor.UnitTests.csproj +++ b/EnumerableAsyncProcessor.UnitTests/EnumerableAsyncProcessor.UnitTests.csproj @@ -1,7 +1,8 @@ - net9.0 + + net9.0;net8.0 enable false + + + From d63780f9e331a6370d69446c45b9a9093d8fff68 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:33:25 +0100 Subject: [PATCH 08/11] fix(ci): commit GitVersion.yml to avoid generated Mainline config ModularPipelines writes a "strategies: [Mainline]" GitVersion config when the repo has none, and that strategy crashes GitVersion 6 with InvalidOperationException on GitHub PR merge commits (reproduced locally against refs/pull/332/merge; the default strategies succeed on the same commit). A committed config takes precedence. Default strategies plus ContinuousDeployment with an empty label on main keep publish versions stable: main resolves to 3.7.0, PR refs to pre-release versions. --- GitVersion.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 GitVersion.yml diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..1876939 --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,8 @@ +# ModularPipelines generates a Mainline-strategy config when this file is absent, +# which crashes GitVersion on GitHub PR merge commits. Keep default strategies and +# give main stable (non pre-release) versions for publishing. +branches: + main: + mode: ContinuousDeployment + label: '' + From a0b5a076d47f98569c8fd6a808a953f9b9042424 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:38:06 +0100 Subject: [PATCH 09/11] style: add missing final newlines to changed files --- .../TaskWrapperStructValidationTests.cs | 2 +- EnumerableAsyncProcessor/Extensions/EnumerableExtensions.cs | 2 +- EnumerableAsyncProcessor/Extensions/ParallelExtensions.cs | 2 +- .../RunnableProcessors/BatchAsyncProcessor.cs | 2 +- .../RunnableProcessors/BatchAsyncProcessor_1.cs | 2 +- .../RunnableProcessors/ParallelAsyncProcessor.cs | 2 +- .../RunnableProcessors/ParallelAsyncProcessor_1.cs | 2 +- .../RunnableProcessors/RateLimitedParallelAsyncProcessor.cs | 2 +- .../RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs | 2 +- .../ResultProcessors/ResultBatchAsyncProcessor_1.cs | 2 +- .../ResultProcessors/ResultBatchAsyncProcessor_2.cs | 2 +- .../ResultProcessors/ResultParallelAsyncProcessor_1.cs | 2 +- .../ResultProcessors/ResultParallelAsyncProcessor_2.cs | 2 +- .../ResultRateLimitedParallelAsyncProcessor_1.cs | 2 +- .../ResultRateLimitedParallelAsyncProcessor_2.cs | 2 +- .../ResultTimedRateLimitedParallelAsyncProcessor_1.cs | 2 +- .../ResultTimedRateLimitedParallelAsyncProcessor_2.cs | 2 +- .../TimedRateLimitedParallelAsyncProcessor.cs | 2 +- .../TimedRateLimitedParallelAsyncProcessor_1.cs | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/EnumerableAsyncProcessor.UnitTests/TaskWrapperStructValidationTests.cs b/EnumerableAsyncProcessor.UnitTests/TaskWrapperStructValidationTests.cs index ccc0758..43e01a5 100644 --- a/EnumerableAsyncProcessor.UnitTests/TaskWrapperStructValidationTests.cs +++ b/EnumerableAsyncProcessor.UnitTests/TaskWrapperStructValidationTests.cs @@ -119,4 +119,4 @@ public async Task TaskWrapper_MemoryFootprint_IsSmallerThanObjects() var hasNonNullFactory = wrappers[0].TaskFactory != null; // Prevent optimization await Assert.That(hasNonNullFactory).IsTrue(); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/Extensions/EnumerableExtensions.cs b/EnumerableAsyncProcessor/Extensions/EnumerableExtensions.cs index b79d32d..c333f7a 100644 --- a/EnumerableAsyncProcessor/Extensions/EnumerableExtensions.cs +++ b/EnumerableAsyncProcessor/Extensions/EnumerableExtensions.cs @@ -168,4 +168,4 @@ internal static async IAsyncEnumerable ToIAsyncEnumerable(this IEnumerable #endif } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/Extensions/ParallelExtensions.cs b/EnumerableAsyncProcessor/Extensions/ParallelExtensions.cs index df3a344..4fe27ae 100644 --- a/EnumerableAsyncProcessor/Extensions/ParallelExtensions.cs +++ b/EnumerableAsyncProcessor/Extensions/ParallelExtensions.cs @@ -120,4 +120,4 @@ private static async Task ProcessAsync( } } } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor.cs index 06f9f10..7fc67f1 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor.cs @@ -28,4 +28,4 @@ private async Task ProcessBatch(ActionTaskWrapper[] taskWrappers) { await Task.WhenAll(taskWrappers.Select(tw => tw.Process(CancellationToken))).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor_1.cs index 6b5dfd4..b8e91ec 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/BatchAsyncProcessor_1.cs @@ -29,4 +29,4 @@ private async Task ProcessBatch(ItemTaskWrapper[] currentBatch) { await Task.WhenAll(currentBatch.Select(tw => tw.Process(CancellationToken))).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor.cs index a4712b6..e512d84 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor.cs @@ -48,4 +48,4 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper => // one queued task, closure and semaphore wait per item await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, minimumIterationTime: null, CancellationToken).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor_1.cs index ec976ca..9dcdc42 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ParallelAsyncProcessor_1.cs @@ -48,4 +48,4 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper => // one queued task, closure and semaphore wait per item await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, minimumIterationTime: null, CancellationToken).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs index 44cbcac..df8147e 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor.cs @@ -18,4 +18,4 @@ internal override Task Process() { return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: null, CancellationToken); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs index f4ec2d5..32dab70 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/RateLimitedParallelAsyncProcessor_1.cs @@ -18,4 +18,4 @@ internal override Task Process() { return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: null, CancellationToken); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs index f035c4e..52c3a02 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs @@ -30,4 +30,4 @@ private async Task ProcessBatch(ActionTaskWrapper[] batch) { await Task.WhenAll(batch.Select(tw => tw.Process(CancellationToken))).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs index 064097c..7ae1d64 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_2.cs @@ -29,4 +29,4 @@ private async Task ProcessBatch(ItemTaskWrapper[] currentBatch) { await Task.WhenAll(currentBatch.Select(tw => tw.Process(CancellationToken))).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_1.cs index bc509da..f85d1c4 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_1.cs @@ -48,4 +48,4 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper => // one queued task, closure and semaphore wait per item await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, minimumIterationTime: null, CancellationToken).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_2.cs index fa36303..2348103 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultParallelAsyncProcessor_2.cs @@ -48,4 +48,4 @@ await Task.WhenAll(TaskWrappers.Select(taskWrapper => // one queued task, closure and semaphore wait per item await WorkerPool.ProcessAsync(TaskWrappers, _maxConcurrency.Value, minimumIterationTime: null, CancellationToken).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs index e3abbfe..c42f06e 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_1.cs @@ -19,4 +19,4 @@ internal override Task Process() { return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: null, CancellationToken); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs index 752fef4..5753020 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultRateLimitedParallelAsyncProcessor_2.cs @@ -18,4 +18,4 @@ internal override Task Process() { return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: null, CancellationToken); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs index a984106..bc85f94 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_1.cs @@ -21,4 +21,4 @@ internal override Task Process() { return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs index ccc9a1a..e1d1160 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultTimedRateLimitedParallelAsyncProcessor_2.cs @@ -21,4 +21,4 @@ internal override Task Process() { return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs index a312168..430cdf5 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor.cs @@ -21,4 +21,4 @@ internal override Task Process() { return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken); } -} \ No newline at end of file +} diff --git a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs index a595f7c..ba8c2c0 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/TimedRateLimitedParallelAsyncProcessor_1.cs @@ -21,4 +21,4 @@ internal override Task Process() { return WorkerPool.ProcessAsync(TaskWrappers, _levelsOfParallelism, minimumIterationTime: _timeSpan, CancellationToken); } -} \ No newline at end of file +} From 861d774dab6184f29f858fde62dbe516ce7aea15 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:43:05 +0100 Subject: [PATCH 10/11] chore: pin next-version 4.0.0 and fix double blank line This PR ships breaking changes (eager input materialization, non-blocking sync Dispose, removed caps, trimmed wrapper-struct surface), so it releases as a major version. Verified locally: main resolves to 4.0.0, PR refs to 4.0.0 pre-releases. Also collapse the double blank line in ResultBatchAsyncProcessor_1.cs flagged by CodeFactor. --- .../page-2026-07-21T17-41-50-921Z.yml | 115 ++++++++++++++++++ .../ResultBatchAsyncProcessor_1.cs | 1 - GitVersion.yml | 1 + 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 .playwright-mcp/page-2026-07-21T17-41-50-921Z.yml diff --git a/.playwright-mcp/page-2026-07-21T17-41-50-921Z.yml b/.playwright-mcp/page-2026-07-21T17-41-50-921Z.yml new file mode 100644 index 0000000..8e0e0ec --- /dev/null +++ b/.playwright-mcp/page-2026-07-21T17-41-50-921Z.yml @@ -0,0 +1,115 @@ +- generic [active] [ref=e1]: + - main [ref=e2]: + - navigation [ref=e5]: + - link "Homepage" [ref=e6] [cursor=pointer]: + - /url: / + - generic [ref=e13]: + - list [ref=e14]: + - listitem [ref=e15]: + - link "Features" [ref=e16] [cursor=pointer]: + - /url: /features + - listitem [ref=e17]: + - link "Pricing" [ref=e18] [cursor=pointer]: + - /url: /pricing + - listitem [ref=e19]: + - link "Contact" [ref=e20] [cursor=pointer]: + - /url: /contact + - list [ref=e21]: + - generic [ref=e22]: "Login with:" + - generic [ref=e23]: + - button [ref=e24] [cursor=pointer]: + - img "GitHub" [ref=e25] + - button [ref=e26] [cursor=pointer]: + - img "Bitbucket" [ref=e27] + - generic [ref=e29]: + - generic [ref=e30]: + - generic [ref=e31]: + - generic "Default branch main" [ref=e33]: main + - link "EnumerableAsyncProcessor" [ref=e35] [cursor=pointer]: + - /url: /repository/github/thomhurst/enumerableasyncprocessor + - generic [ref=e36]: + - img "Repository badge with A rating" [ref=e38] [cursor=pointer] + - link [ref=e39] [cursor=pointer]: + - /url: https://github.com/thomhurst/EnumerableAsyncProcessor + - img "View on GitHub" [ref=e40] + - separator [ref=e41] + - list [ref=e42]: + - listitem [ref=e43]: + - link "11 issues" [ref=e44] [cursor=pointer]: + - /url: /repository/github/thomhurst/enumerableasyncprocessor/issues + - listitem [ref=e45]: + - link "9 pull requests" [ref=e46] [cursor=pointer]: + - /url: /repository/github/thomhurst/enumerableasyncprocessor/pulls + - listitem [ref=e47]: + - link "83 files" [ref=e48] [cursor=pointer]: + - /url: /repository/github/thomhurst/enumerableasyncprocessor/files + - listitem [ref=e49]: + - link "1 active branch" [ref=e50] [cursor=pointer]: + - /url: /repository/github/thomhurst/enumerableasyncprocessor/branches + - generic [ref=e51]: + - generic [ref=e53]: + - generic [ref=e54]: Open + - heading [level=2] [ref=e55]: + - text: "Fix processor correctness: lazy re-enumeration, broken cancellation on dispose, hung awaiters" + - link "#332" [ref=e56] [cursor=pointer]: + - /url: https://github.com/thomhurst/EnumerableAsyncProcessor/pull/332 + - generic [ref=e57]: + - generic [ref=e58]: + - img "Author avatar" [ref=e59] + - text: thomhurst + - text: wants to merge changes into + - generic [ref=e60]: main + - text: from + - generic [ref=e61]: fix/async-processor-correctness + - link "1 new issue" [ref=e62] [cursor=pointer]: + - /url: /repository/github/thomhurst/enumerableasyncprocessor/pull/332 + - generic [ref=e63]: + - navigation [ref=e64]: + - list [ref=e65]: + - listitem [ref=e66]: + - generic [ref=e67] [cursor=pointer]: + - img "Autofix available" [ref=e68] + - text: Maintainability 1 + - generic [ref=e69]: + - heading "All issues" [level=1] [ref=e72] + - button "Actions" [ref=e75] [cursor=pointer] + - generic [ref=e77]: + - generic [ref=e80]: + - 'img "Severity: Minor" [ref=e81]' + - text: Code should not contain multiple blank lines in a row. + - generic [ref=e82]: lines of code = 1 + - generic [ref=e87]: + - generic [ref=e88]: + - text: Found in + - link "EnumerableAsyncProcessor\\RunnableProcessors\\ResultProcessors\\ResultBatchAsyncProcessor_1.cs:3" [ref=e90] [cursor=pointer]: + - /url: https://github.com/thomhurst/EnumerableAsyncProcessor/blob/eda8d646e542b1bc3a2eecfbc43be6fcb3877a27/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs#L3 + - generic [ref=e91]: + - link "3" [ref=e93] [cursor=pointer]: + - /url: https://github.com/thomhurst/EnumerableAsyncProcessor/blob/eda8d646e542b1bc3a2eecfbc43be6fcb3877a27/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs#L3 + - generic [ref=e94]: + - mark + - contentinfo [ref=e95]: + - generic [ref=e97]: + - list [ref=e99]: + - listitem [ref=e100]: + - link "Security" [ref=e101] [cursor=pointer]: + - /url: /security + - listitem [ref=e102]: + - link "Terms" [ref=e103] [cursor=pointer]: + - /url: /terms + - listitem [ref=e104]: + - link "Privacy" [ref=e105] [cursor=pointer]: + - /url: /privacy + - listitem [ref=e106]: + - link "Contact" [ref=e107] [cursor=pointer]: + - /url: /contact + - link [ref=e110] [cursor=pointer]: + - /url: https://x.com/codefactor_io + - generic [ref=e114]: © 2026 CodeFactor + - generic [ref=e117]: + - paragraph [ref=e118]: + - text: We use cookies in order to offer you the most relevant information. See our + - link "Privacy Policy" [ref=e119] [cursor=pointer]: + - /url: /privacy + - text: . + - button "Ok, got it!" [ref=e120] [cursor=pointer] \ No newline at end of file diff --git a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs index 52c3a02..639da5f 100644 --- a/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs +++ b/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs @@ -1,7 +1,6 @@ using EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors.Abstract; using EnumerableAsyncProcessor.Validation; - namespace EnumerableAsyncProcessor.RunnableProcessors.ResultProcessors; public class ResultBatchAsyncProcessor : ResultAbstractAsyncProcessor diff --git a/GitVersion.yml b/GitVersion.yml index 1876939..77aa22b 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,6 +1,7 @@ # ModularPipelines generates a Mainline-strategy config when this file is absent, # which crashes GitVersion on GitHub PR merge commits. Keep default strategies and # give main stable (non pre-release) versions for publishing. +next-version: 4.0.0 branches: main: mode: ContinuousDeployment From 0db5f1cee8abd91060db5f77ade4c6cad05321c1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:43:17 +0100 Subject: [PATCH 11/11] chore: remove accidentally committed tool artifact, ignore .playwright-mcp --- .gitignore | 2 + .../page-2026-07-21T17-41-50-921Z.yml | 115 ------------------ 2 files changed, 2 insertions(+), 115 deletions(-) delete mode 100644 .playwright-mcp/page-2026-07-21T17-41-50-921Z.yml diff --git a/.gitignore b/.gitignore index 377bb11..5313adb 100644 --- a/.gitignore +++ b/.gitignore @@ -351,3 +351,5 @@ MigrationBackup/ # Claude local settings .claude/settings.local.json + +.playwright-mcp/ diff --git a/.playwright-mcp/page-2026-07-21T17-41-50-921Z.yml b/.playwright-mcp/page-2026-07-21T17-41-50-921Z.yml deleted file mode 100644 index 8e0e0ec..0000000 --- a/.playwright-mcp/page-2026-07-21T17-41-50-921Z.yml +++ /dev/null @@ -1,115 +0,0 @@ -- generic [active] [ref=e1]: - - main [ref=e2]: - - navigation [ref=e5]: - - link "Homepage" [ref=e6] [cursor=pointer]: - - /url: / - - generic [ref=e13]: - - list [ref=e14]: - - listitem [ref=e15]: - - link "Features" [ref=e16] [cursor=pointer]: - - /url: /features - - listitem [ref=e17]: - - link "Pricing" [ref=e18] [cursor=pointer]: - - /url: /pricing - - listitem [ref=e19]: - - link "Contact" [ref=e20] [cursor=pointer]: - - /url: /contact - - list [ref=e21]: - - generic [ref=e22]: "Login with:" - - generic [ref=e23]: - - button [ref=e24] [cursor=pointer]: - - img "GitHub" [ref=e25] - - button [ref=e26] [cursor=pointer]: - - img "Bitbucket" [ref=e27] - - generic [ref=e29]: - - generic [ref=e30]: - - generic [ref=e31]: - - generic "Default branch main" [ref=e33]: main - - link "EnumerableAsyncProcessor" [ref=e35] [cursor=pointer]: - - /url: /repository/github/thomhurst/enumerableasyncprocessor - - generic [ref=e36]: - - img "Repository badge with A rating" [ref=e38] [cursor=pointer] - - link [ref=e39] [cursor=pointer]: - - /url: https://github.com/thomhurst/EnumerableAsyncProcessor - - img "View on GitHub" [ref=e40] - - separator [ref=e41] - - list [ref=e42]: - - listitem [ref=e43]: - - link "11 issues" [ref=e44] [cursor=pointer]: - - /url: /repository/github/thomhurst/enumerableasyncprocessor/issues - - listitem [ref=e45]: - - link "9 pull requests" [ref=e46] [cursor=pointer]: - - /url: /repository/github/thomhurst/enumerableasyncprocessor/pulls - - listitem [ref=e47]: - - link "83 files" [ref=e48] [cursor=pointer]: - - /url: /repository/github/thomhurst/enumerableasyncprocessor/files - - listitem [ref=e49]: - - link "1 active branch" [ref=e50] [cursor=pointer]: - - /url: /repository/github/thomhurst/enumerableasyncprocessor/branches - - generic [ref=e51]: - - generic [ref=e53]: - - generic [ref=e54]: Open - - heading [level=2] [ref=e55]: - - text: "Fix processor correctness: lazy re-enumeration, broken cancellation on dispose, hung awaiters" - - link "#332" [ref=e56] [cursor=pointer]: - - /url: https://github.com/thomhurst/EnumerableAsyncProcessor/pull/332 - - generic [ref=e57]: - - generic [ref=e58]: - - img "Author avatar" [ref=e59] - - text: thomhurst - - text: wants to merge changes into - - generic [ref=e60]: main - - text: from - - generic [ref=e61]: fix/async-processor-correctness - - link "1 new issue" [ref=e62] [cursor=pointer]: - - /url: /repository/github/thomhurst/enumerableasyncprocessor/pull/332 - - generic [ref=e63]: - - navigation [ref=e64]: - - list [ref=e65]: - - listitem [ref=e66]: - - generic [ref=e67] [cursor=pointer]: - - img "Autofix available" [ref=e68] - - text: Maintainability 1 - - generic [ref=e69]: - - heading "All issues" [level=1] [ref=e72] - - button "Actions" [ref=e75] [cursor=pointer] - - generic [ref=e77]: - - generic [ref=e80]: - - 'img "Severity: Minor" [ref=e81]' - - text: Code should not contain multiple blank lines in a row. - - generic [ref=e82]: lines of code = 1 - - generic [ref=e87]: - - generic [ref=e88]: - - text: Found in - - link "EnumerableAsyncProcessor\\RunnableProcessors\\ResultProcessors\\ResultBatchAsyncProcessor_1.cs:3" [ref=e90] [cursor=pointer]: - - /url: https://github.com/thomhurst/EnumerableAsyncProcessor/blob/eda8d646e542b1bc3a2eecfbc43be6fcb3877a27/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs#L3 - - generic [ref=e91]: - - link "3" [ref=e93] [cursor=pointer]: - - /url: https://github.com/thomhurst/EnumerableAsyncProcessor/blob/eda8d646e542b1bc3a2eecfbc43be6fcb3877a27/EnumerableAsyncProcessor/RunnableProcessors/ResultProcessors/ResultBatchAsyncProcessor_1.cs#L3 - - generic [ref=e94]: - - mark - - contentinfo [ref=e95]: - - generic [ref=e97]: - - list [ref=e99]: - - listitem [ref=e100]: - - link "Security" [ref=e101] [cursor=pointer]: - - /url: /security - - listitem [ref=e102]: - - link "Terms" [ref=e103] [cursor=pointer]: - - /url: /terms - - listitem [ref=e104]: - - link "Privacy" [ref=e105] [cursor=pointer]: - - /url: /privacy - - listitem [ref=e106]: - - link "Contact" [ref=e107] [cursor=pointer]: - - /url: /contact - - link [ref=e110] [cursor=pointer]: - - /url: https://x.com/codefactor_io - - generic [ref=e114]: © 2026 CodeFactor - - generic [ref=e117]: - - paragraph [ref=e118]: - - text: We use cookies in order to offer you the most relevant information. See our - - link "Privacy Policy" [ref=e119] [cursor=pointer]: - - /url: /privacy - - text: . - - button "Ok, got it!" [ref=e120] [cursor=pointer] \ No newline at end of file