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

Information
Class: AsyncResponse.SerialExecutorRegistry
Assembly: AsyncResponse.Core
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/SerialExecutorRegistry.cs
Line coverage
100%
Covered lines: 105
Uncovered lines: 0
Coverable lines: 105
Total lines: 251
Line coverage: 100%
Branch coverage
100%
Covered branches: 52
Total branches: 52
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%
.cctor()100%11100%
OnSubscriptionRegistered(...)100%22100%
OnSubscriptionRetired(...)100%44100%
EnqueueAsync()94.44%181894.29%
RemoveAsync()100%1212100%
IsTombstonedUnderLock(...)100%44100%
PruneTombstonesUnderLock()100%1212100%
.ctor(...)100%11100%

File(s)

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

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2
 3namespace AsyncResponse;
 4
 5/// <summary>
 6/// Owns the per-channel <see cref="ChannelSerialExecutor"/> instances for a response channel and
 7/// coordinates their lifecycle so that, for any one channel key, work is never enqueued onto an
 8/// executor that is concurrently being retired — which would silently drop the message — and at
 9/// most one <em>live</em> executor exists per channel at a time.
 10/// <para>
 11/// The coordination is a single lock guarding the map. <see cref="EnqueueAsync"/> reserves the live
 12/// executor under that lock, then waits for bounded queue capacity outside it. Retirement closes
 13/// admission, waits for those reservations, drains the executor, and only then lets a new executor
 14/// be created. Disposal runs outside the lock.
 15/// </para>
 16/// <para>
 17/// This replaces an earlier <c>ConcurrentDictionary</c> + fire-and-forget <c>Task.Run(remove)</c>
 18/// scheme in which a new waiter reusing a correlation id mid-drain could observe a second executor
 19/// for the same channel, briefly violating the per-channel ordering guarantee.
 20/// </para>
 21/// </summary>
 322internal sealed class SerialExecutorRegistry(ILogger _logger)
 23{
 24    // How long a retired channel's tombstone blocks executor re-creation. Long enough to outlive
 25    // any enqueue that was already in flight when cleanup retired the executor, short enough that
 26    // an unpruned tombstone only ever delays a reused correlation id briefly.
 327    internal static readonly TimeSpan TombstoneLifetime = TimeSpan.FromSeconds(30);
 28
 329    private readonly Dictionary<string, ExecutorEntry> _executors = new(StringComparer.Ordinal);
 330    private readonly Dictionary<string, DateTime> _tombstones = new(StringComparer.Ordinal);
 331    private readonly Dictionary<string, int> _registrations = new(StringComparer.Ordinal);
 332    private readonly object _gate = new();
 33
 34    /// <summary>
 35    /// Records a live subscription for <paramref name="channel"/>. While any subscription is
 36    /// registered, retirement tombstones do not drop work — a retired executor is legitimately
 37    /// recreated, and the remaining subscription's own cleanup retires it again (no leak).
 38    /// </summary>
 39    public void OnSubscriptionRegistered(string channel)
 40    {
 341        ArgumentException.ThrowIfNullOrWhiteSpace(channel);
 42
 343        lock (_gate)
 44        {
 345            _registrations[channel] = _registrations.TryGetValue(channel, out var count) ? count + 1 : 1;
 346            _tombstones.Remove(channel);
 347        }
 348    }
 49
 50    /// <summary>Records that a subscription for <paramref name="channel"/> is gone.</summary>
 51    public void OnSubscriptionRetired(string channel)
 52    {
 353        ArgumentException.ThrowIfNullOrWhiteSpace(channel);
 54
 355        lock (_gate)
 56        {
 357            if (!_registrations.TryGetValue(channel, out var count))
 358                return;
 59
 360            if (count <= 1)
 361                _registrations.Remove(channel);
 62            else
 363                _registrations[channel] = count - 1;
 364        }
 365    }
 66
 67    /// <summary>
 68    /// Asynchronously enqueues work, applying bounded per-channel backpressure. Returns <c>true</c>
 69    /// once the work is accepted by a live executor; <c>false</c> when it was suppressed by a
 70    /// tombstone (the executor was retired with no registration left — every dispatch it ever
 71    /// admitted has fully completed, so a caller draining before disposal knows nothing is in
 72    /// flight).
 73    /// </summary>
 74    public async ValueTask<bool> EnqueueAsync(string channel, Func<Task> work, CancellationToken cancellationToken = def
 75    {
 376        ArgumentException.ThrowIfNullOrWhiteSpace(channel);
 377        ArgumentNullException.ThrowIfNull(work);
 78
 179        while (true)
 80        {
 381            ExecutorEntry? entry = null;
 382            Task? retirement = null;
 383            lock (_gate)
 84            {
 385                if (!_executors.TryGetValue(channel, out var current))
 86                {
 87                    // A tombstoned channel was retired and no subscription is registered anymore:
 88                    // recreating an executor here (typically for an enqueue that was already in
 89                    // flight when cleanup ran) would leak it — nothing retires it again — and the
 90                    // work item would no-op anyway because the subscriptions are gone. Drop it.
 91                    // With a subscription still registered the recreate is legitimate (its own
 92                    // cleanup retires the new executor), so the tombstone does not apply.
 393                    if (!_registrations.ContainsKey(channel) && IsTombstonedUnderLock(channel))
 94                    {
 95                        // Deliberate, but never silent: if this fires for a live waiter, its channel
 96                        // registered the subscription only after the transport began delivering.
 397                        _logger.LogWarning(
 398                            "Suppressed a delivery for channel {Channel}: the channel is tombstoned and has no registere
 399                            channel);
 3100                        return false;
 101                    }
 102
 3103                    current = new ExecutorEntry(new ChannelSerialExecutor(_logger, channel));
 3104                    _executors[channel] = current;
 105                }
 106
 3107                if (current.Retiring)
 3108                    retirement = current.Retired.Task;
 109                else
 110                {
 3111                    current.InFlightEnqueues++;
 3112                    entry = current;
 113                }
 3114            }
 115
 3116            if (entry is null)
 117            {
 3118                await retirement!.WaitAsync(cancellationToken).ConfigureAwait(false);
 2119                continue;
 120            }
 121
 122            bool accepted;
 123            try
 124            {
 3125                accepted = await entry.Executor.Enqueue(work, cancellationToken).ConfigureAwait(false);
 3126            }
 127            finally
 128            {
 3129                TaskCompletionSource? enqueuesDrained = null;
 3130                lock (_gate)
 131                {
 3132                    entry.InFlightEnqueues--;
 3133                    if (entry.Retiring && entry.InFlightEnqueues == 0)
 2134                        enqueuesDrained = entry.EnqueuesDrained;
 3135                }
 136
 3137                enqueuesDrained?.TrySetResult();
 138            }
 139
 3140            if (accepted)
 3141                return true;
 1142        }
 3143    }
 144
 145    /// <summary>
 146    /// Retires the channel's serial executor (if present), draining its queued work. Safe to call
 147    /// concurrently with <see cref="EnqueueAsync"/>: admitted enqueues finish against the retiring
 148    /// executor, while later enqueues wait until it is fully drained before creating a replacement.
 149    /// </summary>
 150    public async ValueTask RemoveAsync(string channel)
 151    {
 3152        ArgumentException.ThrowIfNullOrWhiteSpace(channel);
 153
 154        ExecutorEntry? entry;
 155        Task waitForEnqueues;
 3156        var ownsRetirement = false;
 3157        lock (_gate)
 158        {
 3159            if (!_executors.TryGetValue(channel, out entry))
 3160                return;
 161
 3162            if (entry.Retiring)
 163            {
 3164                waitForEnqueues = entry.Retired.Task;
 165            }
 166            else
 167            {
 3168                entry.Retiring = true;
 3169                ownsRetirement = true;
 3170                if (entry.InFlightEnqueues == 0)
 171                {
 3172                    waitForEnqueues = Task.CompletedTask;
 173                }
 174                else
 175                {
 3176                    entry.EnqueuesDrained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
 2177                    waitForEnqueues = entry.EnqueuesDrained.Task;
 178                }
 179            }
 3180        }
 181
 3182        if (!ownsRetirement)
 183        {
 3184            await waitForEnqueues.ConfigureAwait(false);
 3185            return;
 186        }
 187
 188        try
 189        {
 3190            await waitForEnqueues.ConfigureAwait(false);
 3191            await entry.Executor.DisposeAsync().ConfigureAwait(false);
 3192        }
 193        finally
 194        {
 3195            lock (_gate)
 196            {
 3197                if (_executors.TryGetValue(channel, out var current) && ReferenceEquals(current, entry))
 3198                    _executors.Remove(channel);
 199
 200                // Tombstone the retired channel so an enqueue that raced this retirement cannot
 201                // recreate a leaked executor; ClearTombstone lifts it the moment a new
 202                // subscription legitimately reuses the channel.
 3203                _tombstones[channel] = DateTime.UtcNow + TombstoneLifetime;
 3204                PruneTombstonesUnderLock();
 3205            }
 206
 3207            entry.Retired.TrySetResult();
 208        }
 3209    }
 210
 211    private bool IsTombstonedUnderLock(string channel)
 212    {
 3213        if (!_tombstones.TryGetValue(channel, out var expiresAtUtc))
 3214            return false;
 215
 3216        if (expiresAtUtc > DateTime.UtcNow)
 3217            return true;
 218
 3219        _tombstones.Remove(channel);
 2220        return false;
 221    }
 222
 223    private void PruneTombstonesUnderLock()
 224    {
 3225        if (_tombstones.Count == 0)
 1226            return;
 227
 3228        var now = DateTime.UtcNow;
 3229        List<string>? expired = null;
 3230        foreach (var (channel, expiresAtUtc) in _tombstones)
 231        {
 3232            if (expiresAtUtc <= now)
 3233                (expired ??= []).Add(channel);
 234        }
 235
 3236        if (expired is null)
 3237            return;
 238
 3239        foreach (var channel in expired)
 3240            _tombstones.Remove(channel);
 3241    }
 242
 3243    private sealed class ExecutorEntry(ChannelSerialExecutor executor)
 244    {
 3245        public ChannelSerialExecutor Executor { get; } = executor;
 3246        public TaskCompletionSource Retired { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
 247        public TaskCompletionSource? EnqueuesDrained { get; set; }
 248        public int InFlightEnqueues { get; set; }
 249        public bool Retiring { get; set; }
 250    }
 251}