< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.ChannelSerialExecutor
Assembly: AsyncResponse.Core
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/ChannelSerialExecutor.cs
Line coverage
100%
Covered lines: 63
Uncovered lines: 0
Coverable lines: 63
Total lines: 164
Line coverage: 100%
Branch coverage
100%
Covered branches: 22
Total branches: 22
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_PendingCount()100%11100%
.ctor(...)100%22100%
DrainAsync()100%88100%
Enqueue(...)100%22100%
EnqueueCoreAsync()100%22100%
TryEnqueue(...)100%44100%
DisposeAsync()100%44100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/ChannelSerialExecutor.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using System.Threading.Channels;
 3
 4namespace AsyncResponse;
 5
 6/// <summary>
 7/// Executes asynchronous work items for a specific channel serially: a bounded
 8/// <see cref="Channel{T}"/> drained by a single reader loop guarantees per-channel ordering, so
 9/// progress messages for one correlation id are never processed concurrently or out of order.
 10/// <para>
 11/// This is a deliberately lean replacement for an earlier <c>ActionBlock&lt;Func&lt;Task&gt;&gt;</c>:
 12/// the rest of the hot path is hand-tuned to avoid allocations, and a single reader over a
 13/// <see cref="System.Threading.Channels"/> queue gives the same strict serial, in-order, one-at-a-time
 14/// semantics without TPL Dataflow's per-item task/scheduler overhead or the dependency it pulls in.
 15/// </para>
 16/// </summary>
 17internal sealed class ChannelSerialExecutor : IAsyncDisposable
 18{
 19    internal const int DefaultCapacity = 1024;
 20    private readonly Channel<Func<Task>> _queue;
 21    private readonly Task _readerLoop;
 22    private readonly ILogger _logger;
 23    private readonly string _channel;
 24
 25    // Items waiting for capacity or accepted into the queue but not yet pulled out for execution.
 26    // The item currently running is no longer "pending".
 27    private int _pending;
 28
 29    /// <summary>How many work items are currently waiting for capacity or waiting to run.</summary>
 330    private int PendingCount => Volatile.Read(ref _pending);
 31
 32    /// <summary>Runs the ChannelSerialExecutor operation.</summary>
 333    public ChannelSerialExecutor(ILogger logger, string channel, int capacity = DefaultCapacity)
 34    {
 335        if (capacity <= 0)
 336            throw new ArgumentOutOfRangeException(nameof(capacity));
 37
 338        _logger = logger;
 339        _channel = channel;
 40
 41        // Waiting writers apply backpressure instead of allowing an overloaded correlation id to
 42        // retain an unbounded delegate backlog. One reader preserves strict per-key ordering.
 343        _queue = Channel.CreateBounded<Func<Task>>(new BoundedChannelOptions(capacity)
 344        {
 345            SingleReader = true,
 346            SingleWriter = false,
 347            AllowSynchronousContinuations = false,
 348            FullMode = BoundedChannelFullMode.Wait
 349        });
 50
 351        _readerLoop = Task.Run(DrainAsync);
 352    }
 53
 54    /// <summary>
 55    /// The single consumer: pulls work items in FIFO order and runs them one at a time. A work item
 56    /// that throws is logged and swallowed so the loop stays alive for the rest of the queue —
 57    /// exactly the resilience the old ActionBlock body provided.
 58    /// </summary>
 59    private async Task DrainAsync()
 60    {
 361        var reader = _queue.Reader;
 362        while (await reader.WaitToReadAsync().ConfigureAwait(false))
 63        {
 364            while (reader.TryRead(out var work))
 65            {
 366                Interlocked.Decrement(ref _pending);
 67
 368                if (_logger.IsEnabled(LogLevel.Debug))
 369                    _logger.LogDebug("Channel executor starting work for {Channel} (pending {PendingCount}).", _channel,
 70
 71                try
 72                {
 373                    await work().ConfigureAwait(false);
 374                }
 375                catch (Exception ex)
 76                {
 377                    _logger.LogError(ex, "Channel executor error for {Channel} (pending {PendingCount}).", _channel, Pen
 78                    // swallow, so the loop stays alive
 279                }
 80                finally
 81                {
 382                    if (_logger.IsEnabled(LogLevel.Debug))
 383                        _logger.LogDebug("Channel executor completed work for {Channel} (pending {PendingCount}).", _cha
 84                }
 85            }
 86        }
 87
 388        if (_logger.IsEnabled(LogLevel.Debug))
 389            _logger.LogDebug("Channel {Channel} executor completed (pending {PendingCount}).", _channel, PendingCount);
 390    }
 91
 92    /// <summary>
 93    /// Queues a work delegate for execution. The returned task completes when the item has been
 94    /// accepted into the queue (not when the work is finished). Returns <c>false</c> when the
 95    /// executor is already shutting down (the queue was completed by <see cref="DisposeAsync"/>).
 96    /// </summary>
 97    public Task<bool> Enqueue(Func<Task> work, CancellationToken cancellationToken = default)
 98    {
 399        ArgumentNullException.ThrowIfNull(work);
 3100        if (cancellationToken.IsCancellationRequested)
 3101            return Task.FromCanceled<bool>(cancellationToken);
 102
 3103        return EnqueueCoreAsync(work, cancellationToken);
 104    }
 105
 106    private async Task<bool> EnqueueCoreAsync(Func<Task> work, CancellationToken cancellationToken)
 107    {
 3108        Interlocked.Increment(ref _pending);
 109        try
 110        {
 3111            await _queue.Writer.WriteAsync(work, cancellationToken).ConfigureAwait(false);
 3112            if (_logger.IsEnabled(LogLevel.Debug))
 3113                _logger.LogDebug("Channel executor enqueued work for {Channel} (pending {PendingCount}).", _channel, Pen
 3114            return true;
 115        }
 3116        catch (ChannelClosedException)
 117        {
 3118            Interlocked.Decrement(ref _pending);
 2119            return false;
 120        }
 2121        catch
 122        {
 2123            Interlocked.Decrement(ref _pending);
 2124            throw;
 125        }
 3126    }
 127
 128    /// <summary>
 129    /// Synchronously queues a work delegate, returning <c>false</c> when the executor is already
 130    /// shutting down or full. Use <see cref="Enqueue"/> when the producer can wait for capacity.
 131    /// </summary>
 132    public bool TryEnqueue(Func<Task> work)
 133    {
 3134        ArgumentNullException.ThrowIfNull(work);
 2135        Interlocked.Increment(ref _pending);
 2136        if (_queue.Writer.TryWrite(work))
 137        {
 2138            if (_logger.IsEnabled(LogLevel.Debug))
 2139                _logger.LogDebug("Channel executor enqueued work for {Channel} (pending {PendingCount}).", _channel, Pen
 2140            return true;
 141        }
 142
 2143        Interlocked.Decrement(ref _pending);
 2144        _logger.LogWarning("Channel executor could not enqueue work for {Channel}; queue is full or completed (pending {
 2145        return false;
 146    }
 147
 148    /// <summary>
 149    /// Signals that no more work items will be posted and waits for queued work to complete.
 150    /// </summary>
 151    public async ValueTask DisposeAsync()
 152    {
 3153        if (_logger.IsEnabled(LogLevel.Debug))
 3154            _logger.LogDebug("Disposing channel executor for {Channel} (pending {PendingCount}).", _channel, PendingCoun
 155
 156        // Complete the writer so the reader drains the remaining items and the loop exits; then wait
 157        // for the loop to finish so callers can rely on all queued work having run.
 3158        _queue.Writer.TryComplete();
 3159        await _readerLoop.ConfigureAwait(false);
 160
 3161        if (_logger.IsEnabled(LogLevel.Debug))
 3162            _logger.LogDebug("Disposed channel executor for {Channel} (pending {PendingCount}).", _channel, PendingCount
 3163    }
 164}