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

Information
Class: AsyncResponse.InMemoryWorkerTransportOptions
Assembly: AsyncResponse.Core
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/InMemoryWorkerTransport.cs
Line coverage
100%
Covered lines: 7
Uncovered lines: 0
Coverable lines: 7
Total lines: 198
Line coverage: 100%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
Validate()100%44100%

File(s)

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

#LineLine coverage
 1using Microsoft.Extensions.Hosting;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using System.Diagnostics;
 5using System.Threading.Channels;
 6
 7namespace AsyncResponse;
 8
 9/// <summary>
 10/// An in-memory <see cref="IWorkerTransport"/> backed by a bounded
 11/// <see cref="Channel{T}"/>, registered by <c>AddAsyncResponse().WithInMemoryTransport()</c>.
 12/// Jobs run in the current process and survive only as long as it does — use a broker-backed
 13/// transport for durability. Intended for development, tests, and single-node deployments.
 14/// <para>
 15/// Because the job stays in-process, the enqueuer's <see cref="ExecutionContext"/> is captured and
 16/// the job runs under it (see <see cref="InMemoryWorkerHost"/>), so ambient <see cref="AsyncLocal{T}"/>
 17/// state — trace id, principal, logging scope — flows automatically without any serializable
 18/// context propagator.
 19/// </para>
 20/// </summary>
 21public sealed class InMemoryWorkerTransport : IWorkerTransport
 22{
 23    private readonly Channel<QueuedJob> _queue;
 24    private int _outstanding;
 25    private volatile bool _draining;
 26
 27    /// <summary>Creates a transport with default bounded-queue options.</summary>
 28    public InMemoryWorkerTransport()
 29        : this(Microsoft.Extensions.Options.Options.Create(new InMemoryWorkerTransportOptions()))
 30    {
 31    }
 32
 33    /// <summary>Creates a transport with configured capacity and worker concurrency.</summary>
 34    public InMemoryWorkerTransport(IOptions<InMemoryWorkerTransportOptions> options)
 35    {
 36        Options = options.Value;
 37        Options.Validate();
 38        _queue = Channel.CreateBounded<QueuedJob>(new BoundedChannelOptions(Options.QueueCapacity)
 39        {
 40            SingleReader = Options.WorkerCount == 1,
 41            SingleWriter = false,
 42            FullMode = BoundedChannelFullMode.Wait,
 43            AllowSynchronousContinuations = false
 44        });
 45    }
 46
 47    internal ChannelReader<QueuedJob> Reader => _queue.Reader;
 48    internal InMemoryWorkerTransportOptions Options { get; }
 49
 50    /// <summary>
 51    /// Begins the shutdown drain. Called by <see cref="InMemoryWorkerHost"/> when the host starts
 52    /// stopping. The writer is deliberately NOT completed while anything is queued or running:
 53    /// accepted jobs were promised in-process execution, and a draining job may legitimately
 54    /// enqueue follow-up work (a durable-flow parent wake-up, a recovery re-enqueue) that must not
 55    /// hit a closed channel — losing it would strand the dependent flow with no redelivery to
 56    /// recover it. The last finishing job completes the writer instead, once the transport is idle.
 57    /// </summary>
 58    internal void BeginShutdownDrain()
 59    {
 60        _draining = true;
 61        // Interlocked read pairs with the increment in PublishAsync: either this sees the
 62        // publisher's count (the finishing job completes the writer) or the publisher's write
 63        // lands before completion. Only a publish initiated after the transport is already idle
 64        // and draining can observe a completed channel.
 65        if (Interlocked.CompareExchange(ref _outstanding, 0, 0) == 0)
 66            _queue.Writer.TryComplete();
 67    }
 68
 69    /// <summary>
 70    /// Called by the worker host after a dequeued job finished (successfully or not). A job counts
 71    /// as outstanding from publish until here, so follow-up publishes made while it runs always
 72    /// find the writer open during the drain.
 73    /// </summary>
 74    internal void OnJobFinished()
 75    {
 76        if (Interlocked.Decrement(ref _outstanding) == 0 && _draining)
 77            _queue.Writer.TryComplete();
 78    }
 79
 80    /// <inheritdoc/>
 81    public async Task PublishAsync(WorkerJobEnvelope job, CancellationToken cancellationToken = default)
 82    {
 83        ArgumentNullException.ThrowIfNull(job);
 84
 85        using var activity = AsyncResponseDiagnostics.StartActivity(
 86            "asyncresponse.worker.publish",
 87            ActivityKind.Producer,
 88            job.CorrelationId);
 89        activity?.SetTag("asyncresponse.transport", "inmemory");
 90        AsyncResponseDiagnostics.SetReplyTarget(activity, job.ReplyTarget);
 91        AsyncResponseDiagnostics.SetWorker(activity, job.Call);
 92
 93        Interlocked.Increment(ref _outstanding);
 94        try
 95        {
 96            await _queue.Writer.WriteAsync(new QueuedJob(job, ExecutionContext.Capture()), cancellationToken).ConfigureA
 97        }
 98        catch (Exception ex)
 99        {
 100            Interlocked.Decrement(ref _outstanding);
 101            AsyncResponseDiagnostics.SetError(activity, ex);
 102            throw;
 103        }
 104    }
 105
 106    /// <summary>A queued job paired with the ambient execution context captured when it was enqueued.</summary>
 107    internal readonly record struct QueuedJob(WorkerJobEnvelope Job, ExecutionContext? Context);
 108}
 109
 110/// <summary>Capacity and concurrency options for the process-local worker transport.</summary>
 111public sealed class InMemoryWorkerTransportOptions
 112{
 113    /// <summary>Maximum queued jobs before publishers asynchronously wait. Default: 1024.</summary>
 2114    public int QueueCapacity { get; set; } = 1024;
 115
 116    /// <summary>Number of jobs that may execute concurrently. Default: 1.</summary>
 2117    public int WorkerCount { get; set; } = 1;
 118
 119    internal void Validate()
 120    {
 2121        if (QueueCapacity <= 0)
 2122            throw new InvalidOperationException($"{nameof(QueueCapacity)} must be positive.");
 2123        if (WorkerCount <= 0)
 2124            throw new InvalidOperationException($"{nameof(WorkerCount)} must be positive.");
 2125    }
 126}
 127
 128/// <summary>
 129/// Background consumer for <see cref="InMemoryWorkerTransport"/>: drains the queue and executes
 130/// each job via <see cref="WorkerJobExecutor"/>, under the enqueuer's captured
 131/// <see cref="ExecutionContext"/> so ambient context flows in-process. Failures are logged and
 132/// never break the loop.
 133/// </summary>
 134internal sealed class InMemoryWorkerHost(
 135    InMemoryWorkerTransport _transport,
 136    WorkerJobExecutor _executor,
 137    ILogger<InMemoryWorkerHost> _logger) : BackgroundService
 138{
 139    /// <summary>Runs this background operation until cancellation is requested.</summary>
 140    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 141    {
 142        // Shutdown quiesces instead of cancelling the readers: accepted jobs were promised
 143        // in-process execution, so the workers drain the queue — including follow-up work those
 144        // jobs enqueue while draining — and the writer completes only once the transport is idle.
 145        // The drain is bounded because the queue is bounded and each job's follow-ups are finite.
 146        using var stopRegistration = stoppingToken.Register(static state =>
 147            ((InMemoryWorkerTransport)state!).BeginShutdownDrain(), _transport);
 148
 149        try
 150        {
 151            var workers = new Task[_transport.Options.WorkerCount];
 152            for (var index = 0; index < workers.Length; index++)
 153                workers[index] = RunWorkerAsync(stoppingToken);
 154            await Task.WhenAll(workers).ConfigureAwait(false);
 155        }
 156        catch (OperationCanceledException)
 157        {
 158            // Host shutdown.
 159        }
 160    }
 161
 162    private async Task RunWorkerAsync(CancellationToken stoppingToken)
 163    {
 164        // Deliberately no cancellation token on the read: the loop ends when the completed queue
 165        // is empty, never by abandoning accepted jobs mid-queue.
 166        await foreach (var queued in _transport.Reader.ReadAllAsync().ConfigureAwait(false))
 167        {
 168            if (stoppingToken.IsCancellationRequested && _logger.IsEnabled(LogLevel.Debug))
 169                _logger.LogDebug("Draining in-memory worker job {Target}.{Method} during shutdown.", queued.Job.Call.Ser
 170
 171            try
 172            {
 173                await RunAsync(queued).ConfigureAwait(false);
 174            }
 175            catch (Exception ex)
 176            {
 177                _logger.LogError(ex, "In-memory worker job {Target}.{Method} failed.", queued.Job.Call.ServiceInterfaceF
 178            }
 179            finally
 180            {
 181                _transport.OnJobFinished();
 182            }
 183        }
 184    }
 185
 186    private Task RunAsync(InMemoryWorkerTransport.QueuedJob queued)
 187    {
 188        // No captured context (flow suppressed): execute directly.
 189        if (queued.Context is null)
 190            return _executor.ExecuteAsync(queued.Job);
 191
 192        // Run under the enqueue-time ExecutionContext so the job inherits its ambient AsyncLocals.
 193        Task? task = null;
 194        ExecutionContext.Run(queued.Context, _ => task = _executor.ExecuteAsync(queued.Job), null);
 195        return task!;
 196    }
 197
 198}

Methods/Properties

.ctor()
Validate()