| | | 1 | | using Microsoft.Extensions.Logging; |
| | | 2 | | |
| | | 3 | | namespace 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> |
| | 1934 | 22 | | internal sealed class SerialExecutorRegistry( |
| | 1934 | 23 | | ILogger _logger, |
| | 1934 | 24 | | TimeSpan? disposeDrainLimit = null, |
| | 1934 | 25 | | TimeSpan? enqueueDrainLimit = null, |
| | 1934 | 26 | | TimeProvider? timeProvider = null) |
| | | 27 | | { |
| | | 28 | | // How long a retired channel's tombstone blocks executor re-creation. Long enough to outlive |
| | | 29 | | // any enqueue that was already in flight when cleanup retired the executor, short enough that |
| | | 30 | | // an unpruned tombstone only ever delays a reused correlation id briefly. |
| | 14 | 31 | | internal static readonly TimeSpan TombstoneLifetime = TimeSpan.FromSeconds(30); |
| | | 32 | | |
| | | 33 | | // Upper bound on how long retirement waits for in-flight enqueues to drain. A producer can be |
| | | 34 | | // parked indefinitely awaiting queue capacity with a token that never fires, and teardown |
| | | 35 | | // paths await RemoveAsync directly — they must not inherit that hang. |
| | 14 | 36 | | internal static readonly TimeSpan EnqueueDrainLimit = TimeSpan.FromSeconds(30); |
| | | 37 | | |
| | | 38 | | // Upper bound on how long retirement waits for the executor's dispatched work to finish. A |
| | | 39 | | // dispatched item runs arbitrary user code (a completion predicate that never finishes), and |
| | | 40 | | // an unbounded wait here would wedge the channel key permanently — every later enqueue for |
| | | 41 | | // the correlation id parks on the never-completed retirement, including a NEW waiter that |
| | | 42 | | // legitimately re-registered the id. |
| | 14 | 43 | | internal static readonly TimeSpan DisposeDrainLimit = TimeSpan.FromSeconds(30); |
| | | 44 | | |
| | 1934 | 45 | | private readonly TimeSpan _disposeDrainLimit = disposeDrainLimit ?? DisposeDrainLimit; |
| | | 46 | | |
| | | 47 | | // Overridable alongside _disposeDrainLimit: RemoveAsync waits on BOTH budgets in sequence, so |
| | | 48 | | // a caller that could shorten only one still paid the other's full 30 seconds — and the |
| | | 49 | | // worst-case retirement became "the value I passed, plus 30s" rather than the value passed. |
| | 1934 | 50 | | private readonly TimeSpan _enqueueDrainLimit = enqueueDrainLimit ?? EnqueueDrainLimit; |
| | | 51 | | |
| | | 52 | | // Tombstone expiry runs on the injected clock so a virtual-clock harness can advance past |
| | | 53 | | // TombstoneLifetime instead of sleeping 30 real seconds. Without it, the drop-a-delivery |
| | | 54 | | // branch in EnqueueAsync (and tombstone pruning) could not be covered deterministically. |
| | 1934 | 55 | | private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; |
| | | 56 | | |
| | 3553 | 57 | | private DateTimeOffset UtcNow => _timeProvider.GetUtcNow(); |
| | | 58 | | |
| | 1934 | 59 | | private readonly Dictionary<string, ExecutorEntry> _executors = new(StringComparer.Ordinal); |
| | 1934 | 60 | | private readonly Dictionary<string, DateTimeOffset> _tombstones = new(StringComparer.Ordinal); |
| | | 61 | | |
| | | 62 | | /// <summary> |
| | | 63 | | /// Expiry-ordered index over <see cref="_tombstones"/> (constant lifetime, so insertion order |
| | | 64 | | /// is expiry order). Pruning pops expired heads instead of scanning the whole dictionary — |
| | | 65 | | /// the scan ran per retired waiter under <see cref="_gate"/>, the same lock every dispatched |
| | | 66 | | /// message's enqueue takes, so its cost grew quadratically with throughput. Entries can be |
| | | 67 | | /// stale (the channel was re-tombstoned later, or the tombstone was cleared); the dictionary |
| | | 68 | | /// stays the source of truth and each popped head is validated against it. |
| | | 69 | | /// </summary> |
| | 1934 | 70 | | private readonly Queue<(string Channel, DateTimeOffset ExpiresAtUtc)> _tombstoneOrder = new(); |
| | 1934 | 71 | | private readonly Dictionary<string, int> _registrations = new(StringComparer.Ordinal); |
| | 1934 | 72 | | private readonly object _gate = new(); |
| | | 73 | | |
| | | 74 | | /// <summary> |
| | | 75 | | /// Records a live subscription for <paramref name="channel"/>. While any subscription is |
| | | 76 | | /// registered, retirement tombstones do not drop work — a retired executor is legitimately |
| | | 77 | | /// recreated, and the remaining subscription's own cleanup retires it again (no leak). |
| | | 78 | | /// </summary> |
| | | 79 | | public void OnSubscriptionRegistered(string channel) |
| | | 80 | | { |
| | 1817 | 81 | | ArgumentException.ThrowIfNullOrWhiteSpace(channel); |
| | | 82 | | |
| | 1817 | 83 | | lock (_gate) |
| | | 84 | | { |
| | 1817 | 85 | | _registrations[channel] = _registrations.TryGetValue(channel, out var count) ? count + 1 : 1; |
| | 1817 | 86 | | _tombstones.Remove(channel); |
| | 1817 | 87 | | } |
| | 1817 | 88 | | } |
| | | 89 | | |
| | | 90 | | /// <summary>Records that a subscription for <paramref name="channel"/> is gone.</summary> |
| | | 91 | | public void OnSubscriptionRetired(string channel) |
| | | 92 | | { |
| | 1735 | 93 | | ArgumentException.ThrowIfNullOrWhiteSpace(channel); |
| | | 94 | | |
| | 1735 | 95 | | lock (_gate) |
| | | 96 | | { |
| | 1735 | 97 | | if (!_registrations.TryGetValue(channel, out var count)) |
| | 4 | 98 | | return; |
| | | 99 | | |
| | 1731 | 100 | | if (count <= 1) |
| | 1717 | 101 | | _registrations.Remove(channel); |
| | | 102 | | else |
| | 14 | 103 | | _registrations[channel] = count - 1; |
| | 14 | 104 | | } |
| | 1735 | 105 | | } |
| | | 106 | | |
| | | 107 | | /// <summary> |
| | | 108 | | /// Asynchronously enqueues work, applying bounded per-channel backpressure. Returns <c>true</c> |
| | | 109 | | /// once the work is accepted by a live executor; <c>false</c> when it was suppressed by a |
| | | 110 | | /// tombstone (the executor was retired with no registration left — every dispatch it ever |
| | | 111 | | /// admitted has fully completed, so a caller draining before disposal knows nothing is in |
| | | 112 | | /// flight). |
| | | 113 | | /// </summary> |
| | | 114 | | public async ValueTask<bool> EnqueueAsync(string channel, Func<Task> work, CancellationToken cancellationToken = def |
| | | 115 | | { |
| | 5144 | 116 | | ArgumentException.ThrowIfNullOrWhiteSpace(channel); |
| | 5144 | 117 | | ArgumentNullException.ThrowIfNull(work); |
| | | 118 | | |
| | 0 | 119 | | while (true) |
| | | 120 | | { |
| | 5146 | 121 | | ExecutorEntry? entry = null; |
| | 5146 | 122 | | Task? retirement = null; |
| | 5146 | 123 | | lock (_gate) |
| | | 124 | | { |
| | 5146 | 125 | | if (!_executors.TryGetValue(channel, out var current)) |
| | | 126 | | { |
| | | 127 | | // A tombstoned channel was retired and no subscription is registered anymore: |
| | | 128 | | // recreating an executor here (typically for an enqueue that was already in |
| | | 129 | | // flight when cleanup ran) would leak it — nothing retires it again — and the |
| | | 130 | | // work item would no-op anyway because the subscriptions are gone. Drop it. |
| | | 131 | | // With a subscription still registered the recreate is legitimate (its own |
| | | 132 | | // cleanup retires the new executor), so the tombstone does not apply. |
| | 1322 | 133 | | if (!_registrations.ContainsKey(channel) && IsTombstonedUnderLock(channel)) |
| | | 134 | | { |
| | | 135 | | // Deliberate, but never silent: if this fires for a live waiter, its channel |
| | | 136 | | // registered the subscription only after the transport began delivering. |
| | 6 | 137 | | _logger.LogWarning( |
| | 6 | 138 | | "Suppressed a delivery for channel {Channel}: the channel is tombstoned and has no registere |
| | 6 | 139 | | channel); |
| | 6 | 140 | | return false; |
| | | 141 | | } |
| | | 142 | | |
| | 1316 | 143 | | current = new ExecutorEntry(new ChannelSerialExecutor(_logger, channel)); |
| | 1316 | 144 | | _executors[channel] = current; |
| | | 145 | | } |
| | | 146 | | |
| | 5140 | 147 | | if (current.Retiring) |
| | 2 | 148 | | retirement = current.Retired.Task; |
| | | 149 | | else |
| | | 150 | | { |
| | 5138 | 151 | | current.InFlightEnqueues++; |
| | 5138 | 152 | | entry = current; |
| | | 153 | | } |
| | 5140 | 154 | | } |
| | | 155 | | |
| | 5140 | 156 | | if (entry is null) |
| | | 157 | | { |
| | 2 | 158 | | await retirement!.WaitAsync(cancellationToken).ConfigureAwait(false); |
| | 2 | 159 | | continue; |
| | | 160 | | } |
| | | 161 | | |
| | | 162 | | bool accepted; |
| | | 163 | | try |
| | | 164 | | { |
| | 5138 | 165 | | accepted = await entry.Executor.Enqueue(work, cancellationToken).ConfigureAwait(false); |
| | 5138 | 166 | | } |
| | | 167 | | finally |
| | | 168 | | { |
| | 5138 | 169 | | TaskCompletionSource? enqueuesDrained = null; |
| | 5138 | 170 | | lock (_gate) |
| | | 171 | | { |
| | 5138 | 172 | | entry.InFlightEnqueues--; |
| | 5138 | 173 | | if (entry.Retiring && entry.InFlightEnqueues == 0) |
| | 2 | 174 | | enqueuesDrained = entry.EnqueuesDrained; |
| | 5138 | 175 | | } |
| | | 176 | | |
| | 5138 | 177 | | enqueuesDrained?.TrySetResult(); |
| | | 178 | | } |
| | | 179 | | |
| | 5138 | 180 | | if (accepted) |
| | 5138 | 181 | | return true; |
| | 0 | 182 | | } |
| | 5144 | 183 | | } |
| | | 184 | | |
| | | 185 | | /// <summary>The outcome of a non-blocking <see cref="TryEnqueue"/>.</summary> |
| | | 186 | | public enum TryEnqueueOutcome |
| | | 187 | | { |
| | | 188 | | /// <summary>Accepted by the channel's live executor.</summary> |
| | | 189 | | Accepted, |
| | | 190 | | |
| | | 191 | | /// <summary> |
| | | 192 | | /// Not accepted right now — the executor's bounded queue is full, or (for a caller that did |
| | | 193 | | /// not ask to tell the two apart) the channel's executor is mid-retirement. The work was not |
| | | 194 | | /// queued; the producer should come back later. |
| | | 195 | | /// </summary> |
| | | 196 | | Full, |
| | | 197 | | |
| | | 198 | | /// <summary>Suppressed by a tombstone (retired executor, no registration left): nothing will ever run it.</summ |
| | | 199 | | Suppressed, |
| | | 200 | | |
| | | 201 | | /// <summary> |
| | | 202 | | /// Not accepted right now because the channel's executor is mid-retirement — nothing is |
| | | 203 | | /// overloaded, and <see cref="EnqueueAsync"/> would wait the retirement out and admit the |
| | | 204 | | /// work onto a fresh executor. Reported only to a caller that passes |
| | | 205 | | /// <c>distinguishRetiring</c>; everyone else keeps reading it as <see cref="Full"/>. |
| | | 206 | | /// </summary> |
| | | 207 | | Retiring |
| | | 208 | | } |
| | | 209 | | |
| | | 210 | | /// <summary> |
| | | 211 | | /// Non-blocking counterpart of <see cref="EnqueueAsync"/>: never waits for queue capacity or |
| | | 212 | | /// for a retirement to finish. Built for the DB channels' process-wide dispatch sweep, which |
| | | 213 | | /// walks every subscribed correlation id in turn: awaiting one correlation id's capacity there |
| | | 214 | | /// parked the whole loop — a single waiter wedged in a slow completion predicate, fed a |
| | | 215 | | /// backlog of NEW progress messages, stopped every other correlation id's delivery until its |
| | | 216 | | /// executor drained. A <see cref="TryEnqueueOutcome.Full"/> result leaves the message |
| | | 217 | | /// unclaimed in the store for a later rescan of that one correlation id. |
| | | 218 | | /// <para> |
| | | 219 | | /// <paramref name="distinguishRetiring"/> is for a producer whose <see cref="TryEnqueueOutcome.Full"/> |
| | | 220 | | /// is TERMINAL rather than "come back later". The sweep above answers a full queue and a |
| | | 221 | | /// retiring executor the same way — the message stays in the store — so it keeps one case. The |
| | | 222 | | /// Redis channel has no store to come back to: a full queue faults the wait as overloaded, and |
| | | 223 | | /// reading a mid-retirement executor (a previous waiter on the same correlation id still |
| | | 224 | | /// tearing down) as that overload faulted a fan-out sibling or a re-attached waiter as |
| | | 225 | | /// indeterminate with nothing overloaded at all. |
| | | 226 | | /// </para> |
| | | 227 | | /// </summary> |
| | | 228 | | public TryEnqueueOutcome TryEnqueue(string channel, Func<Task> work, bool distinguishRetiring = false) |
| | | 229 | | { |
| | 10423 | 230 | | ArgumentException.ThrowIfNullOrWhiteSpace(channel); |
| | 10423 | 231 | | ArgumentNullException.ThrowIfNull(work); |
| | | 232 | | |
| | 10423 | 233 | | lock (_gate) |
| | | 234 | | { |
| | 10423 | 235 | | if (!_executors.TryGetValue(channel, out var current)) |
| | | 236 | | { |
| | | 237 | | // Same tombstone rule as EnqueueAsync: no registration and a live tombstone means |
| | | 238 | | // the work would run against no subscription; recreating an executor would leak it. |
| | 464 | 239 | | if (!_registrations.ContainsKey(channel) && IsTombstonedUnderLock(channel)) |
| | | 240 | | { |
| | 13 | 241 | | _logger.LogWarning( |
| | 13 | 242 | | "Suppressed a delivery for channel {Channel}: the channel is tombstoned and has no registered su |
| | 13 | 243 | | channel); |
| | 13 | 244 | | return TryEnqueueOutcome.Suppressed; |
| | | 245 | | } |
| | | 246 | | |
| | 451 | 247 | | current = new ExecutorEntry(new ChannelSerialExecutor(_logger, channel)); |
| | 451 | 248 | | _executors[channel] = current; |
| | | 249 | | } |
| | | 250 | | |
| | | 251 | | // Mid-retirement: EnqueueAsync would wait for the drain and then recreate; a |
| | | 252 | | // non-blocking caller simply comes back after it. |
| | 10410 | 253 | | if (current.Retiring) |
| | 1 | 254 | | return distinguishRetiring ? TryEnqueueOutcome.Retiring : TryEnqueueOutcome.Full; |
| | | 255 | | |
| | | 256 | | // TryWrite is synchronous and never blocks, so it can run under the gate; no in-flight |
| | | 257 | | // enqueue bookkeeping is needed because nothing is left waiting for capacity. |
| | 10409 | 258 | | return current.Executor.TryEnqueue(work, logIfFull: false) |
| | 10409 | 259 | | ? TryEnqueueOutcome.Accepted |
| | 10409 | 260 | | : TryEnqueueOutcome.Full; |
| | | 261 | | } |
| | 10423 | 262 | | } |
| | | 263 | | |
| | | 264 | | /// <summary> |
| | | 265 | | /// Retires the channel's serial executor (if present), draining its queued work. Safe to call |
| | | 266 | | /// concurrently with <see cref="EnqueueAsync"/>: admitted enqueues finish against the retiring |
| | | 267 | | /// executor, while later enqueues wait until it is fully drained before creating a replacement. |
| | | 268 | | /// </summary> |
| | | 269 | | public async ValueTask RemoveAsync(string channel) |
| | | 270 | | { |
| | 2056 | 271 | | ArgumentException.ThrowIfNullOrWhiteSpace(channel); |
| | | 272 | | |
| | | 273 | | ExecutorEntry? entry; |
| | | 274 | | Task waitForEnqueues; |
| | 2056 | 275 | | var ownsRetirement = false; |
| | 2056 | 276 | | lock (_gate) |
| | | 277 | | { |
| | 2056 | 278 | | if (!_executors.TryGetValue(channel, out entry)) |
| | 275 | 279 | | return; |
| | | 280 | | |
| | 1781 | 281 | | if (entry.Retiring) |
| | | 282 | | { |
| | 16 | 283 | | waitForEnqueues = entry.Retired.Task; |
| | | 284 | | } |
| | | 285 | | else |
| | | 286 | | { |
| | 1765 | 287 | | entry.Retiring = true; |
| | 1765 | 288 | | ownsRetirement = true; |
| | 1765 | 289 | | if (entry.InFlightEnqueues == 0) |
| | | 290 | | { |
| | 1763 | 291 | | waitForEnqueues = Task.CompletedTask; |
| | | 292 | | } |
| | | 293 | | else |
| | | 294 | | { |
| | 2 | 295 | | entry.EnqueuesDrained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) |
| | 2 | 296 | | waitForEnqueues = entry.EnqueuesDrained.Task; |
| | | 297 | | } |
| | | 298 | | } |
| | 1781 | 299 | | } |
| | | 300 | | |
| | 1781 | 301 | | if (!ownsRetirement) |
| | | 302 | | { |
| | 16 | 303 | | await waitForEnqueues.ConfigureAwait(false); |
| | 16 | 304 | | return; |
| | | 305 | | } |
| | | 306 | | |
| | | 307 | | try |
| | | 308 | | { |
| | | 309 | | try |
| | | 310 | | { |
| | | 311 | | // Bounded wait: an admitted enqueue can be parked indefinitely on a full queue |
| | | 312 | | // with a token that never fires. Proceeding after the limit is safe — disposal |
| | | 313 | | // completes the executor's writer, which unparks the wedged producer, and its |
| | | 314 | | // retry then lands on the tombstone/recreate machinery built for exactly the |
| | | 315 | | // enqueue-races-retirement case. |
| | 1765 | 316 | | await waitForEnqueues.WaitAsync(_enqueueDrainLimit, _timeProvider).ConfigureAwait(false); |
| | 1765 | 317 | | } |
| | 0 | 318 | | catch (TimeoutException) |
| | | 319 | | { |
| | 0 | 320 | | _logger.LogWarning( |
| | 0 | 321 | | "Timed out after {DrainLimit} waiting for in-flight enqueues on channel {Channel} to drain; disposin |
| | 0 | 322 | | _enqueueDrainLimit, |
| | 0 | 323 | | channel); |
| | 0 | 324 | | } |
| | | 325 | | |
| | | 326 | | try |
| | | 327 | | { |
| | | 328 | | // Bounded for the same reason: disposal waits for the reader loop, which can be |
| | | 329 | | // parked in a dispatched item's arbitrary user code. The writer is completed |
| | | 330 | | // before disposal first awaits, so the abandoned loop drains and exits on its own |
| | | 331 | | // if the wedged item ever finishes; retiring the entry regardless (the finally |
| | | 332 | | // below) keeps the channel key usable for future waiters. |
| | 1765 | 333 | | await entry.Executor.DisposeAsync().AsTask().WaitAsync(_disposeDrainLimit, _timeProvider).ConfigureAwait |
| | 1757 | 334 | | } |
| | 8 | 335 | | catch (TimeoutException) |
| | | 336 | | { |
| | 8 | 337 | | _logger.LogWarning( |
| | 8 | 338 | | "Timed out after {DrainLimit} waiting for in-flight work on channel {Channel} to finish; abandoning |
| | 8 | 339 | | _disposeDrainLimit, |
| | 8 | 340 | | channel); |
| | 8 | 341 | | } |
| | 1765 | 342 | | } |
| | | 343 | | finally |
| | | 344 | | { |
| | 1765 | 345 | | lock (_gate) |
| | | 346 | | { |
| | 1765 | 347 | | if (_executors.TryGetValue(channel, out var current) && ReferenceEquals(current, entry)) |
| | 1765 | 348 | | _executors.Remove(channel); |
| | | 349 | | |
| | | 350 | | // Tombstone the retired channel so an enqueue that raced this retirement cannot |
| | | 351 | | // recreate a leaked executor; ClearTombstone lifts it the moment a new |
| | | 352 | | // subscription legitimately reuses the channel. |
| | 1765 | 353 | | var tombstoneExpiresAtUtc = UtcNow + TombstoneLifetime; |
| | 1765 | 354 | | _tombstones[channel] = tombstoneExpiresAtUtc; |
| | 1765 | 355 | | _tombstoneOrder.Enqueue((channel, tombstoneExpiresAtUtc)); |
| | 1765 | 356 | | PruneTombstonesUnderLock(); |
| | 1765 | 357 | | } |
| | | 358 | | |
| | 1765 | 359 | | entry.Retired.TrySetResult(); |
| | | 360 | | } |
| | 2056 | 361 | | } |
| | | 362 | | |
| | | 363 | | private bool IsTombstonedUnderLock(string channel) |
| | | 364 | | { |
| | 63 | 365 | | if (!_tombstones.TryGetValue(channel, out var expiresAtUtc)) |
| | 40 | 366 | | return false; |
| | | 367 | | |
| | 23 | 368 | | if (expiresAtUtc > UtcNow) |
| | 19 | 369 | | return true; |
| | | 370 | | |
| | 4 | 371 | | _tombstones.Remove(channel); |
| | 4 | 372 | | return false; |
| | | 373 | | } |
| | | 374 | | |
| | | 375 | | private void PruneTombstonesUnderLock() |
| | | 376 | | { |
| | 1765 | 377 | | if (_tombstoneOrder.Count == 0) |
| | 0 | 378 | | return; |
| | | 379 | | |
| | 1765 | 380 | | var now = UtcNow; |
| | 1772 | 381 | | while (_tombstoneOrder.TryPeek(out var head) && head.ExpiresAtUtc <= now) |
| | | 382 | | { |
| | 7 | 383 | | _tombstoneOrder.Dequeue(); |
| | | 384 | | |
| | | 385 | | // Only drop the tombstone the popped entry still describes: a re-tombstoned channel |
| | | 386 | | // has a later expiry in the dictionary (its own queue entry follows), and a cleared |
| | | 387 | | // one is already gone. |
| | 7 | 388 | | if (_tombstones.TryGetValue(head.Channel, out var current) && current <= now) |
| | 5 | 389 | | _tombstones.Remove(head.Channel); |
| | 5 | 390 | | } |
| | 1765 | 391 | | } |
| | | 392 | | |
| | 1767 | 393 | | private sealed class ExecutorEntry(ChannelSerialExecutor executor) |
| | | 394 | | { |
| | 19079 | 395 | | public ChannelSerialExecutor Executor { get; } = executor; |
| | 3550 | 396 | | public TaskCompletionSource Retired { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); |
| | 6 | 397 | | public TaskCompletionSource? EnqueuesDrained { get; set; } |
| | 22319 | 398 | | public int InFlightEnqueues { get; set; } |
| | 24234 | 399 | | public bool Retiring { get; set; } |
| | | 400 | | } |
| | | 401 | | } |