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

Information
Class: AsyncResponse.SerialExecutorRegistry.ExecutorEntry
Assembly: AsyncResponse.Core
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/SerialExecutorRegistry.cs
Line coverage
100%
Covered lines: 3
Uncovered lines: 0
Coverable lines: 3
Total lines: 251
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.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>
 22internal 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.
 27    internal static readonly TimeSpan TombstoneLifetime = TimeSpan.FromSeconds(30);
 28
 29    private readonly Dictionary<string, ExecutorEntry> _executors = new(StringComparer.Ordinal);
 30    private readonly Dictionary<string, DateTime> _tombstones = new(StringComparer.Ordinal);
 31    private readonly Dictionary<string, int> _registrations = new(StringComparer.Ordinal);
 32    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    {
 41        ArgumentException.ThrowIfNullOrWhiteSpace(channel);
 42
 43        lock (_gate)
 44        {
 45            _registrations[channel] = _registrations.TryGetValue(channel, out var count) ? count + 1 : 1;
 46            _tombstones.Remove(channel);
 47        }
 48    }
 49
 50    /// <summary>Records that a subscription for <paramref name="channel"/> is gone.</summary>
 51    public void OnSubscriptionRetired(string channel)
 52    {
 53        ArgumentException.ThrowIfNullOrWhiteSpace(channel);
 54
 55        lock (_gate)
 56        {
 57            if (!_registrations.TryGetValue(channel, out var count))
 58                return;
 59
 60            if (count <= 1)
 61                _registrations.Remove(channel);
 62            else
 63                _registrations[channel] = count - 1;
 64        }
 65    }
 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    {
 76        ArgumentException.ThrowIfNullOrWhiteSpace(channel);
 77        ArgumentNullException.ThrowIfNull(work);
 78
 79        while (true)
 80        {
 81            ExecutorEntry? entry = null;
 82            Task? retirement = null;
 83            lock (_gate)
 84            {
 85                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.
 93                    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.
 97                        _logger.LogWarning(
 98                            "Suppressed a delivery for channel {Channel}: the channel is tombstoned and has no registere
 99                            channel);
 100                        return false;
 101                    }
 102
 103                    current = new ExecutorEntry(new ChannelSerialExecutor(_logger, channel));
 104                    _executors[channel] = current;
 105                }
 106
 107                if (current.Retiring)
 108                    retirement = current.Retired.Task;
 109                else
 110                {
 111                    current.InFlightEnqueues++;
 112                    entry = current;
 113                }
 114            }
 115
 116            if (entry is null)
 117            {
 118                await retirement!.WaitAsync(cancellationToken).ConfigureAwait(false);
 119                continue;
 120            }
 121
 122            bool accepted;
 123            try
 124            {
 125                accepted = await entry.Executor.Enqueue(work, cancellationToken).ConfigureAwait(false);
 126            }
 127            finally
 128            {
 129                TaskCompletionSource? enqueuesDrained = null;
 130                lock (_gate)
 131                {
 132                    entry.InFlightEnqueues--;
 133                    if (entry.Retiring && entry.InFlightEnqueues == 0)
 134                        enqueuesDrained = entry.EnqueuesDrained;
 135                }
 136
 137                enqueuesDrained?.TrySetResult();
 138            }
 139
 140            if (accepted)
 141                return true;
 142        }
 143    }
 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    {
 152        ArgumentException.ThrowIfNullOrWhiteSpace(channel);
 153
 154        ExecutorEntry? entry;
 155        Task waitForEnqueues;
 156        var ownsRetirement = false;
 157        lock (_gate)
 158        {
 159            if (!_executors.TryGetValue(channel, out entry))
 160                return;
 161
 162            if (entry.Retiring)
 163            {
 164                waitForEnqueues = entry.Retired.Task;
 165            }
 166            else
 167            {
 168                entry.Retiring = true;
 169                ownsRetirement = true;
 170                if (entry.InFlightEnqueues == 0)
 171                {
 172                    waitForEnqueues = Task.CompletedTask;
 173                }
 174                else
 175                {
 176                    entry.EnqueuesDrained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
 177                    waitForEnqueues = entry.EnqueuesDrained.Task;
 178                }
 179            }
 180        }
 181
 182        if (!ownsRetirement)
 183        {
 184            await waitForEnqueues.ConfigureAwait(false);
 185            return;
 186        }
 187
 188        try
 189        {
 190            await waitForEnqueues.ConfigureAwait(false);
 191            await entry.Executor.DisposeAsync().ConfigureAwait(false);
 192        }
 193        finally
 194        {
 195            lock (_gate)
 196            {
 197                if (_executors.TryGetValue(channel, out var current) && ReferenceEquals(current, entry))
 198                    _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.
 203                _tombstones[channel] = DateTime.UtcNow + TombstoneLifetime;
 204                PruneTombstonesUnderLock();
 205            }
 206
 207            entry.Retired.TrySetResult();
 208        }
 209    }
 210
 211    private bool IsTombstonedUnderLock(string channel)
 212    {
 213        if (!_tombstones.TryGetValue(channel, out var expiresAtUtc))
 214            return false;
 215
 216        if (expiresAtUtc > DateTime.UtcNow)
 217            return true;
 218
 219        _tombstones.Remove(channel);
 220        return false;
 221    }
 222
 223    private void PruneTombstonesUnderLock()
 224    {
 225        if (_tombstones.Count == 0)
 226            return;
 227
 228        var now = DateTime.UtcNow;
 229        List<string>? expired = null;
 230        foreach (var (channel, expiresAtUtc) in _tombstones)
 231        {
 232            if (expiresAtUtc <= now)
 233                (expired ??= []).Add(channel);
 234        }
 235
 236        if (expired is null)
 237            return;
 238
 239        foreach (var channel in expired)
 240            _tombstones.Remove(channel);
 241    }
 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}