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

Information
Class: AsyncResponse.InMemoryAsyncResponseChannel<T>
Assembly: AsyncResponse.Core
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/AsyncResponse.Core/InMemoryAsyncResponseChannel.cs
Line coverage
99%
Covered lines: 443
Uncovered lines: 2
Coverable lines: 445
Total lines: 1000
Line coverage: 99.5%
Branch coverage
96%
Covered branches: 219
Total branches: 228
Branch coverage: 96%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
CreateResponseWaiter()95.45%2222100%
SetResponse<T>(...)100%11100%
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponse(...)100%11100%
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponseJson(...)100%11100%
SetResponseCore()93.75%1616100%
SetRawResponseJsonCore()93.75%1616100%
SetException()90%2020100%
CountActiveSubscribersAsync(...)100%44100%
AddSubscription(...)100%44100%
SnapshotSubscribers(...)100%22100%
DispatchResponsesAsync(...)100%44100%
DispatchRawJsonResponsesAsync(...)100%44100%
DispatchExceptionsAsync(...)100%44100%
DispatchManyAsync<TState>(...)100%1616100%
RemoveSubscription(...)100%44100%
ChannelName(...)100%11100%
.ctor()100%11100%
get_Count()100%44100%
TryAdd(...)100%88100%
Remove(...)100%1212100%
Snapshot()100%66100%
.ctor(...)100%11100%
get_Count()100%44100%
ForSingle(...)100%11100%
ForMany(...)100%11100%
.ctor(...)100%11100%
get_WaitActivity()100%11100%
get_CleanupStarted()100%11100%
ArmTimeout()83.33%6683.33%
DispatchExceptionAsync(...)100%22100%
DispatchExceptionCoreAsync(...)100%44100%
DispatchSerialAsync<TState>(...)100%44100%
WaitAndDispatchAsync()100%44100%
ReleaseAfterDispatchAsync()100%11100%
ReleaseDispatch()75%44100%
CleanupOnceAsync()75%44100%
DisposeCleanupAsync()100%44100%
StartCleanupAsync()100%22100%
TryBeginTerminal()100%11100%
CleanupOnceAsTask()50%22100%
TimeoutAsync()100%22100%
TimeoutCoreAsync()100%44100%
.ctor(...)100%11100%
get_ResponseTask()100%11100%
DispatchResponseAsync(...)100%22100%
DispatchResponseUnserializedAsync(...)100%44100%
DispatchRawJsonResponseAsync(...)100%22100%
DispatchRawJsonResponseUnserializedAsync(...)100%44100%
DispatchResponseCoreAsync(...)100%1010100%
DispatchRawJsonResponseCoreAsync(...)100%11100%
DispatchPayloadAsync(...)100%66100%
FaultAsync(...)100%22100%
AwaitCompletionPredicateAsync()100%66100%
SetTimeoutException(...)100%11100%
TrySetException(...)100%11100%
TrySetCanceled()100%11100%

File(s)

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

#LineLine coverage
 1using Microsoft.Extensions.DependencyInjection;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using System.Collections.Concurrent;
 5using System.Diagnostics;
 6using System.Runtime.CompilerServices;
 7
 8namespace AsyncResponse;
 9
 10/// <summary>
 11/// Process-local response channel registered by <c>AddAsyncResponse().WithInMemoryChannel()</c>.
 12/// It provides the async-response programming model without Redis or another broker-backed channel.
 13/// Waiters, subscriptions, and recovery state are all in memory and disappear when the process
 14/// exits.
 15/// </summary>
 16internal sealed class InMemoryAsyncResponseChannel : IAsyncResponsePublisher, IRawAsyncResponsePublisher, IAsyncResponse
 17{
 218    private readonly ConcurrentDictionary<string, SubscriptionGroup> _subscriptions = new(StringComparer.Ordinal);
 19    private readonly IRecoveryStateStore _recoveryStateStore;
 20    private readonly InMemoryAsyncResponseOptions _options;
 21    private readonly LostSubscriberCallbackDispatcher _lostSubscriberDispatcher;
 22    private readonly AsyncResponseContextPropagation _propagation;
 23    private readonly ILogger<InMemoryAsyncResponseChannel> _logger;
 24
 25    /// <summary>Creates a process-local async-response channel.</summary>
 226    public InMemoryAsyncResponseChannel(
 227        IServiceScopeFactory scopeFactory,
 228        IRecoveryStateStore recoveryStateStore,
 229        IOptions<InMemoryAsyncResponseOptions> options,
 230        AsyncResponseContextPropagation propagation,
 231        ILogger<InMemoryAsyncResponseChannel> logger)
 32    {
 233        _recoveryStateStore = recoveryStateStore;
 234        _options = options.Value;
 235        _options.ValidateShared(nameof(InMemoryAsyncResponseOptions));
 236        _propagation = propagation;
 237        _logger = logger;
 238        _lostSubscriberDispatcher = new LostSubscriberCallbackDispatcher(scopeFactory, propagation, logger);
 239    }
 40
 41    /// <inheritdoc />
 42    public async Task<IAsyncResponseWaiter<T>> CreateResponseWaiter<T>(
 43        string correlationId,
 44        Func<T, ValueTask<bool>>? completionPredicate = null,
 45        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 46    {
 247        if (string.IsNullOrWhiteSpace(correlationId))
 248            throw new ArgumentNullException(nameof(correlationId), "CorrelationId must not be empty or whitespace.");
 49
 250        var hasCustomPredicate = completionPredicate is not null;
 251        completionPredicate ??= static _ => new ValueTask<bool>(true);
 252        timeout ??= _options.DefaultTimeout ?? _options.RecoveryStateExpiry;
 53        // BEFORE any side effect: an unsupported resolved timeout (non-positive, or past the
 54        // ~49.7-day BCL timer ceiling) used to throw only at timer arming — after the
 55        // subscription and recovery state existed, leaking both — and zero used to slip through
 56        // on some channels entirely, insta-timing-out a fully registered waiter.
 257        AsyncResponseChannelOptions.EnsureWaiterTimeoutSupported(timeout.Value);
 58
 259        var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.wait", correlationId: correlationId);
 260        activity?.SetTag("asyncresponse.channel", "inmemory");
 261        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 262        activity?.SetTag("asyncresponse.timeout_seconds", timeout.Value.TotalSeconds);
 63
 264        var subscription = new Subscription<T>(
 265            owner: this,
 266            correlationId,
 267            timeout.Value,
 268            completionPredicate,
 269            activity,
 270            // Only restore the subscribe-time ambient context during dispatch when there is a user
 271            // completion predicate to run under it. With the default (always-complete) predicate,
 272            // nothing on the dispatch path observes ambient context, so capturing it would only buy
 273            // a per-dispatch ExecutionContext.Run plus its capturing closure. The waiter's own
 274            // continuation flows its own context regardless (RunContinuationsAsynchronously).
 275            hasCustomPredicate ? ExecutionContext.Capture() : null);
 76
 277        AddSubscription(correlationId, subscription);
 78
 79        try
 80        {
 281            await _recoveryStateStore.SaveAsync(
 282                correlationId,
 283                new RecoveryState
 284                {
 285                    RegistrationId = subscription.Id,
 286                    CorrelationId = correlationId,
 287                    PayloadTypeFullName = typeof(T).FullName,
 288                    RegisteredAtUtc = DateTime.UtcNow,
 289                    Context = _propagation.Capture()
 290                },
 291                _options.RecoveryStateExpiry).ConfigureAwait(false);
 92
 293            if (subscription.CleanupStarted)
 294                await _recoveryStateStore.TryDeleteAsync(correlationId, subscription.Id).ConfigureAwait(false);
 95            else
 296                subscription.ArmTimeout();
 97
 298            if (_logger.IsEnabled(LogLevel.Debug))
 299                _logger.LogDebug("Waiting for response on correlationId {CorrelationId} with timeout {Timeout}.", correl
 2100        }
 2101        catch (Exception ex)
 102        {
 2103            _logger.LogError(ex, "Failed to create in-memory waiter for correlationId {CorrelationId}.", correlationId);
 2104            AsyncResponseDiagnostics.SetError(activity, ex);
 2105            await subscription.DisposeCleanupAsync().ConfigureAwait(false);
 106
 107            // Rethrow instead of returning a pre-faulted waiter: the builder's contract is that
 108            // the trigger runs only once the subscription AND recovery state exist. A returned
 109            // waiter would still let the trigger fire the remote operation with no registration
 110            // left to receive (or recover) its response. Cleanup cancels ResponseTask, so no
 111            // pending task is left behind.
 2112            throw;
 113        }
 114
 2115        return new InMemoryAsyncResponseWaiter<T>(subscription.ResponseTask, subscription.DisposeCleanupAsync);
 2116    }
 117
 118    /// <inheritdoc />
 119    public Task SetResponse<T>(T response, string correlationId, CancellationToken cancellationToken = default) where T 
 2120        => SetResponseCore(response, correlationId, cancellationToken);
 121
 122    Task IRawAsyncResponsePublisher.SetRawResponse(object? response, string correlationId, CancellationToken cancellatio
 2123        => SetResponseCore(response, correlationId, cancellationToken);
 124
 125    Task IRawAsyncResponsePublisher.SetRawResponseJson(string responseJson, string correlationId, CancellationToken canc
 2126        => SetRawResponseJsonCore(new RawJsonResponse(responseJson), correlationId, cancellationToken);
 127
 128    // Intentionally duplicated with SetRawResponseJsonCore: this is a microbenchmarked publish
 129    // hot path. Earlier generic/delegate/helper refactors made the code prettier but measurably
 130    // regressed latency and throughput, so keep the typed path inline unless benchmarks prove out.
 131    private async Task SetResponseCore<T>(T response, string correlationId, CancellationToken cancellationToken)
 132    {
 2133        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_response", ActivityKind.Producer)
 2134        activity?.SetTag("asyncresponse.channel", "inmemory");
 2135        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 136
 2137        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 2138        if (string.IsNullOrWhiteSpace(correlationId))
 139        {
 2140            _logger.LogWarning("CorrelationId is null; cannot publish the response.");
 2141            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 2142            return;
 143        }
 144
 145        try
 146        {
 2147            var subscribers = SnapshotSubscribers(correlationId);
 2148            activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 2149            if (subscribers.Count == 0)
 150            {
 2151                var result = await _lostSubscriberDispatcher
 2152                    .DispatchLostResponses(
 2153                        _recoveryStateStore,
 2154                        correlationId,
 2155                        response,
 2156                        ChannelName(correlationId),
 2157                        cancellationToken,
 2158                        hasLiveSubscriber: () => new ValueTask<bool>(SnapshotSubscribers(correlationId).Count > 0))
 2159                    .ConfigureAwait(false);
 160
 2161                if (!result.RetryLive)
 162                {
 2163                    AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, result.ShouldResume);
 2164                    AsyncResponseDiagnostics.RecordLostSubscriber("response", result.ShouldResume, result.CallbackInvoke
 2165                    activity?.SetTag("asyncresponse.recovery.callback_invoked", result.CallbackInvoked);
 166
 2167                    return;
 168                }
 169
 170                // A waiter registered between the snapshot and the recovery-state read — deliver
 171                // live instead of consuming its registration.
 2172                subscribers = SnapshotSubscribers(correlationId);
 2173                activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 174            }
 175
 2176            await DispatchResponsesAsync(subscribers, response).ConfigureAwait(false);
 177
 2178            if (_logger.IsEnabled(LogLevel.Debug))
 2179                _logger.LogDebug("Published response for correlationId {CorrelationId}. PayloadType: {PayloadType}. Subs
 2180        }
 2181        catch (Exception ex)
 182        {
 2183            AsyncResponseDiagnostics.SetError(activity, ex);
 2184            throw;
 185        }
 2186    }
 187
 188    // Intentionally duplicated with SetResponseCore: raw ingress has different dispatch and
 189    // recovery materialization costs, and keeping the branch inline avoids hot-path indirection.
 190    private async Task SetRawResponseJsonCore(RawJsonResponse response, string correlationId, CancellationToken cancella
 191    {
 2192        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.ingress.raw_response", ActivityKind.P
 2193        activity?.SetTag("asyncresponse.channel", "inmemory");
 194
 2195        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 2196        if (string.IsNullOrWhiteSpace(correlationId))
 197        {
 2198            _logger.LogWarning("CorrelationId is null; cannot publish the raw response.");
 2199            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 2200            return;
 201        }
 202
 203        try
 204        {
 2205            var subscribers = SnapshotSubscribers(correlationId);
 2206            activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 2207            if (subscribers.Count == 0)
 208            {
 2209                var result = await _lostSubscriberDispatcher
 2210                    .DispatchLostResponses(
 2211                        _recoveryStateStore,
 2212                        correlationId,
 2213                        response.DeserializeUntyped(),
 2214                        ChannelName(correlationId),
 2215                        cancellationToken,
 2216                        hasLiveSubscriber: () => new ValueTask<bool>(SnapshotSubscribers(correlationId).Count > 0))
 2217                    .ConfigureAwait(false);
 218
 2219                if (!result.RetryLive)
 220                {
 2221                    AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, result.ShouldResume);
 2222                    AsyncResponseDiagnostics.RecordLostSubscriber("response", result.ShouldResume, result.CallbackInvoke
 2223                    activity?.SetTag("asyncresponse.recovery.callback_invoked", result.CallbackInvoked);
 224
 2225                    return;
 226                }
 227
 228                // A waiter registered between the snapshot and the recovery-state read — deliver
 229                // live instead of consuming its registration.
 2230                subscribers = SnapshotSubscribers(correlationId);
 2231                activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 232            }
 233
 2234            await DispatchRawJsonResponsesAsync(subscribers, response).ConfigureAwait(false);
 235
 2236            if (_logger.IsEnabled(LogLevel.Debug))
 2237                _logger.LogDebug("Published raw response for correlationId {CorrelationId}. Subscribers: {SubscriberCoun
 2238        }
 2239        catch (Exception ex)
 240        {
 2241            AsyncResponseDiagnostics.SetError(activity, ex);
 2242            throw;
 243        }
 2244    }
 245
 246    /// <inheritdoc />
 247    public async Task SetException(Exception exception, string correlationId, CancellationToken cancellationToken = defa
 248    {
 2249        ArgumentNullException.ThrowIfNull(exception);
 250
 2251        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_exception", ActivityKind.Producer
 2252        activity?.SetTag("asyncresponse.channel", "inmemory");
 2253        activity?.SetTag("asyncresponse.exception_type", exception.GetType().FullName ?? exception.GetType().Name);
 254
 2255        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 2256        if (string.IsNullOrWhiteSpace(correlationId))
 257        {
 2258            _logger.LogWarning("CorrelationId is null; cannot publish the exception. Exception: {ExceptionMessage}", exc
 2259            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 2260            return;
 261        }
 262
 263        try
 264        {
 2265            var subscribers = SnapshotSubscribers(correlationId);
 2266            activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 2267            if (subscribers.Count == 0)
 268            {
 2269                var result = await _lostSubscriberDispatcher
 2270                    .DispatchLostExceptions(
 2271                        _recoveryStateStore,
 2272                        correlationId,
 2273                        exception,
 2274                        ChannelName(correlationId),
 2275                        cancellationToken,
 2276                        hasLiveSubscriber: () => new ValueTask<bool>(SnapshotSubscribers(correlationId).Count > 0))
 2277                    .ConfigureAwait(false);
 278
 2279                if (!result.RetryLive)
 280                {
 2281                    activity?.SetTag("asyncresponse.recovery.callback_invoked", result.CallbackInvoked);
 2282                    AsyncResponseDiagnostics.RecordLostSubscriber("exception", shouldResume: false, result.CallbackInvok
 283
 2284                    return;
 285                }
 286
 287                // A waiter registered between the snapshot and the recovery-state read — deliver
 288                // live instead of consuming its registration.
 2289                subscribers = SnapshotSubscribers(correlationId);
 2290                activity?.SetTag("asyncresponse.subscribers", subscribers.Count);
 291            }
 292
 2293            await DispatchExceptionsAsync(subscribers, exception).ConfigureAwait(false);
 294
 2295            if (_logger.IsEnabled(LogLevel.Debug))
 2296                _logger.LogDebug("Published exception for correlationId {CorrelationId}. Subscribers: {SubscriberCount}.
 2297        }
 2298        catch (Exception ex)
 299        {
 2300            AsyncResponseDiagnostics.SetError(activity, ex);
 2301            throw;
 302        }
 2303    }
 304
 305    /// <inheritdoc />
 306    public ValueTask<long> CountActiveSubscribersAsync(string correlationId, CancellationToken cancellationToken = defau
 307    {
 2308        if (string.IsNullOrWhiteSpace(correlationId))
 2309            return new ValueTask<long>(0L);
 310
 2311        long count = _subscriptions.TryGetValue(correlationId, out var subscribers) ? subscribers.Count : 0L;
 2312        return new ValueTask<long>(count);
 313    }
 314
 315    private void AddSubscription(string correlationId, SubscriptionBase subscription)
 316    {
 2317        while (true)
 318        {
 2319            var group = _subscriptions.GetOrAdd(correlationId, static _ => new SubscriptionGroup());
 2320            if (group.TryAdd(subscription))
 2321                return;
 322
 2323            _subscriptions.TryRemove(new KeyValuePair<string, SubscriptionGroup>(correlationId, group));
 324        }
 325    }
 326
 327    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 328    private SubscriptionSnapshot SnapshotSubscribers(string correlationId)
 2329        => _subscriptions.TryGetValue(correlationId, out var subscribers)
 2330            ? subscribers.Snapshot()
 2331            : default;
 332
 333    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 334    private static Task DispatchResponsesAsync(SubscriptionSnapshot subscribers, object? response)
 335    {
 2336        if (subscribers.Single is { } single)
 2337            return single.DispatchResponseAsync(response);
 338
 2339        return DispatchManyAsync(subscribers.Many, static (subscriber, state) => subscriber.DispatchResponseAsync(state)
 340    }
 341
 342    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 343    private static Task DispatchRawJsonResponsesAsync(SubscriptionSnapshot subscribers, RawJsonResponse response)
 344    {
 2345        if (subscribers.Single is { } single)
 2346            return single.DispatchRawJsonResponseAsync(response);
 347
 2348        return DispatchManyAsync(subscribers.Many, static (subscriber, state) => subscriber.DispatchRawJsonResponseAsync
 349    }
 350
 351    [MethodImpl(MethodImplOptions.AggressiveInlining)]
 352    private static Task DispatchExceptionsAsync(SubscriptionSnapshot subscribers, Exception exception)
 353    {
 2354        if (subscribers.Single is { } single)
 2355            return single.DispatchExceptionAsync(exception);
 356
 2357        return DispatchManyAsync(subscribers.Many, static (subscriber, state) => subscriber.DispatchExceptionAsync(state
 358    }
 359
 360    private static Task DispatchManyAsync<TState>(
 361        SubscriptionBase[]? subscribers,
 362        Func<SubscriptionBase, TState, Task> dispatch,
 363        TState state)
 364    {
 2365        if (subscribers is null || subscribers.Length == 0)
 2366            return Task.CompletedTask;
 367
 2368        Task? firstPending = null;
 2369        List<Task>? pending = null;
 2370        for (var i = 0; i < subscribers.Length; i++)
 371        {
 2372            var task = dispatch(subscribers[i], state);
 2373            if (task.IsCompletedSuccessfully)
 374                continue;
 375
 2376            if (firstPending is null)
 377            {
 2378                firstPending = task;
 2379                continue;
 380            }
 381
 2382            (pending ??= [firstPending]).Add(task);
 383        }
 384
 2385        return pending is not null
 2386            ? Task.WhenAll(pending)
 2387            : firstPending ?? Task.CompletedTask;
 388    }
 389
 390    private void RemoveSubscription(string correlationId, Guid subscriptionId)
 391    {
 2392        if (!_subscriptions.TryGetValue(correlationId, out var subscribers))
 2393            return;
 394
 2395        if (subscribers.Remove(subscriptionId))
 2396            _subscriptions.TryRemove(new KeyValuePair<string, SubscriptionGroup>(correlationId, subscribers));
 2397    }
 398
 2399    private static string ChannelName(string correlationId) => $"inmemory:response:{correlationId}";
 400
 401    private sealed class SubscriptionGroup
 402    {
 2403        private readonly object _gate = new();
 404        private SubscriptionBase? _single;
 405        private List<SubscriptionBase>? _many;
 406        private bool _closed;
 407
 408        public int Count
 409        {
 410            get
 411            {
 2412                lock (_gate)
 2413                    return _single is not null ? 1 : _many?.Count ?? 0;
 2414            }
 415        }
 416
 417        /// <summary>Adds a subscription to this correlation-id group.</summary>
 418        public bool TryAdd(SubscriptionBase subscription)
 419        {
 2420            lock (_gate)
 421            {
 2422                if (_closed)
 2423                    return false;
 424
 2425                if (_single is null && _many is null)
 426                {
 2427                    _single = subscription;
 2428                    return true;
 429                }
 430
 2431                if (_many is null)
 432                {
 2433                    _many = [_single!, subscription];
 2434                    _single = null;
 2435                    return true;
 436                }
 437
 2438                _many.Add(subscription);
 2439                return true;
 440            }
 2441        }
 442
 443        /// <summary>Removes a subscription and returns whether the group became empty.</summary>
 444        public bool Remove(Guid subscriptionId)
 445        {
 2446            lock (_gate)
 447            {
 2448                if (_single?.Id == subscriptionId)
 449                {
 2450                    _single = null;
 2451                    _closed = true;
 2452                    return true;
 453                }
 454
 2455                if (_many is null)
 2456                    return false;
 457
 2458                for (var i = 0; i < _many.Count; i++)
 459                {
 2460                    if (_many[i].Id != subscriptionId)
 461                        continue;
 462
 2463                    _many.RemoveAt(i);
 464                    // _many is only ever created with two entries and collapses to _single at one,
 465                    // so it can never reach zero here — the group-empty signal is produced solely
 466                    // by the _single removal path above.
 2467                    if (_many.Count == 1)
 468                    {
 2469                        _single = _many[0];
 2470                        _many = null;
 471                    }
 472
 2473                    return false;
 474                }
 475
 2476                return false;
 477            }
 2478        }
 479
 480        /// <summary>Captures the current subscriptions for lock-free dispatch outside the group lock.</summary>
 481        public SubscriptionSnapshot Snapshot()
 482        {
 2483            lock (_gate)
 484            {
 2485                if (_single is not null)
 2486                    return SubscriptionSnapshot.ForSingle(_single);
 487
 2488                if (_many is { Count: > 0 })
 2489                    return SubscriptionSnapshot.ForMany(_many.ToArray());
 490
 2491                return default;
 492            }
 2493        }
 494    }
 495
 496    private readonly struct SubscriptionSnapshot
 497    {
 498        private SubscriptionSnapshot(SubscriptionBase? single, SubscriptionBase[]? many)
 499        {
 2500            Single = single;
 2501            Many = many;
 2502        }
 503
 504        public SubscriptionBase? Single { get; }
 505        public SubscriptionBase[]? Many { get; }
 506        public int Count
 507        {
 508            [MethodImpl(MethodImplOptions.AggressiveInlining)]
 2509            get => Single is not null ? 1 : Many?.Length ?? 0;
 510        }
 511
 512        /// <summary>Creates a snapshot containing one subscription.</summary>
 513        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 2514        public static SubscriptionSnapshot ForSingle(SubscriptionBase single) => new(single, null);
 515
 516        /// <summary>Creates a snapshot containing multiple subscriptions.</summary>
 517        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 2518        public static SubscriptionSnapshot ForMany(SubscriptionBase[] many) => new(null, many);
 519    }
 520
 521    private abstract class SubscriptionBase
 522    {
 523        private readonly InMemoryAsyncResponseChannel _owner;
 524        private readonly CancellationTokenSource _timeoutCts;
 525        private readonly Activity? _activity;
 2526        private readonly object _cleanupSync = new();
 527        private SemaphoreSlim? _dispatchWaiters;
 528        private CancellationTokenRegistration _timeoutRegistration;
 529        private Task? _cleanupTask;
 530        private int _dispatching;
 531        private int _dispatchWaiterCount;
 532        private int _terminal;
 533        private int _cleanupStarted;
 534
 535        /// <summary>Creates the common state for an in-memory waiter subscription.</summary>
 2536        protected SubscriptionBase(InMemoryAsyncResponseChannel owner, string correlationId, TimeSpan timeout, Activity?
 537        {
 2538            _owner = owner;
 2539            CorrelationId = correlationId;
 2540            Timeout = timeout;
 2541            _activity = activity;
 2542            _timeoutCts = new CancellationTokenSource();
 2543        }
 544
 545        /// <summary>Per-waiter registration id used for subscription and recovery-state cleanup.</summary>
 2546        public Guid Id { get; } = Guid.NewGuid();
 547        protected string CorrelationId { get; }
 2548        protected Activity? WaitActivity => _activity;
 549        private TimeSpan Timeout { get; }
 550        public bool CleanupStarted
 551        {
 552            [MethodImpl(MethodImplOptions.AggressiveInlining)]
 2553            get => Volatile.Read(ref _cleanupStarted) != 0;
 554        }
 555
 556        /// <summary>Arms the subscription timeout after registration has succeeded.</summary>
 557        public void ArmTimeout()
 558        {
 2559            if (CleanupStarted)
 2560                return;
 561
 562            try
 563            {
 2564                _timeoutRegistration = _timeoutCts.Token.Register(static state =>
 2565                {
 2566                    _ = ((SubscriptionBase)state!).TimeoutAsync();
 2567                }, this);
 568
 2569                if (CleanupStarted)
 570                {
 0571                    _timeoutRegistration.Dispose();
 0572                    return;
 573                }
 574
 2575                _timeoutCts.CancelAfter(Timeout);
 2576            }
 2577            catch (ObjectDisposedException)
 578            {
 579                // A response or explicit disposal can clean up between the guard and timer arming.
 2580            }
 2581        }
 582
 583        /// <summary>Dispatches a typed or materializable response to this subscription.</summary>
 584        public abstract Task DispatchResponseAsync(object? response);
 585
 586        /// <summary>Dispatches a raw JSON response to this subscription.</summary>
 587        public abstract Task DispatchRawJsonResponseAsync(RawJsonResponse response);
 588
 589        /// <summary>Faults this subscription with a published exception.</summary>
 590        public Task DispatchExceptionAsync(Exception exception)
 2591            => DispatchSerialAsync(
 2592                exception,
 2593                static (subscription, state) => subscription.DispatchExceptionCoreAsync(state));
 594
 595        private Task DispatchExceptionCoreAsync(Exception exception)
 596        {
 2597            if (CleanupStarted)
 2598                return Task.CompletedTask;
 599
 2600            if (!TryBeginTerminal())
 2601                return Task.CompletedTask;
 602
 2603            AsyncResponseDiagnostics.SetError(_activity, exception);
 2604            TrySetException(exception);
 2605            return CleanupOnceAsTask();
 606        }
 607
 608        /// <summary>
 609        /// Serializes every signal for one waiter. The uncontended path uses only an interlocked
 610        /// owner bit; the semaphore is created lazily if concurrent publishers actually contend.
 611        /// </summary>
 612        protected Task DispatchSerialAsync<TState>(
 613            TState state,
 614            Func<SubscriptionBase, TState, Task> dispatch)
 615        {
 2616            if (Interlocked.CompareExchange(ref _dispatching, 1, 0) != 0)
 2617                return WaitAndDispatchAsync(this, state, dispatch);
 618
 619            Task task;
 620            try
 621            {
 2622                task = dispatch(this, state);
 2623            }
 2624            catch
 625            {
 2626                ReleaseDispatch();
 2627                throw;
 628            }
 629
 2630            if (task.IsCompletedSuccessfully)
 631            {
 2632                ReleaseDispatch();
 2633                return task;
 634            }
 635
 2636            return ReleaseAfterDispatchAsync(this, task);
 637        }
 638
 639        private static async Task WaitAndDispatchAsync<TState>(
 640            SubscriptionBase subscription,
 641            TState state,
 642            Func<SubscriptionBase, TState, Task> dispatch)
 643        {
 2644            Interlocked.Increment(ref subscription._dispatchWaiterCount);
 645            try
 646            {
 2647                var waiters = LazyInitializer.EnsureInitialized(
 2648                    ref subscription._dispatchWaiters,
 2649                    static () => new SemaphoreSlim(0));
 2650                while (Interlocked.CompareExchange(ref subscription._dispatching, 1, 0) != 0)
 2651                    await waiters.WaitAsync().ConfigureAwait(false);
 2652            }
 653            finally
 654            {
 2655                Interlocked.Decrement(ref subscription._dispatchWaiterCount);
 656            }
 657
 658            try
 659            {
 2660                await dispatch(subscription, state).ConfigureAwait(false);
 2661            }
 662            finally
 663            {
 2664                subscription.ReleaseDispatch();
 665            }
 2666        }
 667
 668        private static async Task ReleaseAfterDispatchAsync(SubscriptionBase subscription, Task task)
 669        {
 670            try
 671            {
 2672                await task.ConfigureAwait(false);
 2673            }
 674            finally
 675            {
 2676                subscription.ReleaseDispatch();
 677            }
 2678        }
 679
 680        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 681        private void ReleaseDispatch()
 682        {
 2683            Volatile.Write(ref _dispatching, 0);
 2684            if (Volatile.Read(ref _dispatchWaiterCount) > 0)
 2685                Volatile.Read(ref _dispatchWaiters)?.Release();
 2686        }
 687
 688        /// <summary>Runs subscription, recovery-state, timeout, and activity cleanup once.</summary>
 689        public ValueTask CleanupOnceAsync()
 690        {
 691            Task cleanupTask;
 2692            lock (_cleanupSync)
 693            {
 2694                cleanupTask = _cleanupTask ??= StartCleanupAsync();
 2695            }
 696
 2697            return cleanupTask.IsCompletedSuccessfully
 2698                ? ValueTask.CompletedTask
 2699                : new ValueTask(cleanupTask);
 700        }
 701
 702        /// <summary>
 703        /// Dispose-path cleanup: DRAINS any in-flight dispatch before settling. A delivery may be
 704        /// mid <c>Until</c>-predicate holding a claimed terminal message; queueing a no-op through
 705        /// the per-waiter dispatch gate completes only after that delivery settled the task (or
 706        /// released the gate), so the cleanup's cancel afterwards is a genuine settlement — never
 707        /// a cancellation stealing an already-consumed response. Must NOT be called from dispatch
 708        /// code (which holds the gate): dispatch-triggered cleanup uses
 709        /// <see cref="CleanupOnceAsync"/> directly, with its task already settled.
 710        /// <para>
 711        /// The drain is bounded by <c>DisposalDrainTimeout</c>. A lapsed budget must not fall back
 712        /// to the cleanup's cancel — the wedged delivery holds a message the channel already
 713        /// claimed, and "canceled" would tell a re-attaching caller nothing was delivered — so it
 714        /// faults the task with the explicit indeterminate contract instead, routing durable flows
 715        /// to a fresh idempotent restart. The abandoned no-op marker runs harmlessly whenever the
 716        /// wedged dispatch finally releases the gate.
 717        /// </para>
 718        /// </summary>
 719        public async ValueTask DisposeCleanupAsync()
 720        {
 2721            if (Volatile.Read(ref _cleanupStarted) == 0)
 722            {
 2723                var drainTimeout = _owner._options.DisposalDrainTimeout;
 724                try
 725                {
 2726                    await DispatchSerialAsync(0, static (_, _) => Task.CompletedTask)
 2727                        .WaitAsync(drainTimeout).ConfigureAwait(false);
 2728                }
 2729                catch (TimeoutException)
 730                {
 2731                    _owner._logger.LogWarning(
 2732                        "Disposal drain for correlationId {CorrelationId} did not finish within {DrainTimeout}; faulting
 2733                        CorrelationId, drainTimeout);
 2734                    AsyncResponseDiagnostics.SetError(_activity, "indeterminate_delivery", "Disposal drain timed out wit
 735                    // A TrySetResult from the late-finishing dispatch loses against this and is
 736                    // dropped; its cleanup call is a no-op behind the latch.
 2737                    TrySetException(new AsyncResponseIndeterminateDeliveryException(CorrelationId, drainTimeout));
 2738                }
 739            }
 740
 2741            await CleanupOnceAsync().ConfigureAwait(false);
 2742        }
 743
 744        private async Task StartCleanupAsync()
 745        {
 2746            Volatile.Write(ref _cleanupStarted, 1);
 747
 748            // A waiter disposed before any terminal signal must not leave ResponseTask pending
 749            // forever for callers that hold it directly. Cancellation is a no-op after a normal
 750            // completion, timeout, or fault.
 2751            TrySetCanceled();
 752
 753            try
 754            {
 755                // Delete the recovery state BEFORE removing the subscription. In the reverse order
 756                // a publish landing in the window sees "no subscriber, state present" and fires a
 757                // spurious recovery callback for a wait that already reached a terminal state. In
 758                // this order the window shows a subscriber that drops the message (CleanupStarted)
 759                // — a late or duplicate terminal message is droppable; a resurrected recovery
 760                // callback is not.
 2761                await _owner._recoveryStateStore.TryDeleteAsync(CorrelationId, Id).ConfigureAwait(false);
 762            }
 763            finally
 764            {
 2765                _owner.RemoveSubscription(CorrelationId, Id);
 2766                await _timeoutRegistration.DisposeAsync().ConfigureAwait(false);
 2767                _timeoutCts.Dispose();
 2768                _activity?.Dispose();
 769            }
 2770        }
 771
 772        /// <summary>Marks this subscription as terminal if no terminal signal has won yet.</summary>
 773        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 774        protected bool TryBeginTerminal()
 2775            => Interlocked.Exchange(ref _terminal, 1) == 0;
 776
 777        /// <summary>Stores the timeout exception on the concrete waiter task.</summary>
 778        protected abstract void SetTimeoutException(Exception exception);
 779
 780        /// <summary>Attempts to fault the concrete waiter task.</summary>
 781        public abstract void TrySetException(Exception exception);
 782
 783        /// <summary>Attempts to cancel the concrete waiter task (dispose before any terminal signal).</summary>
 784        public abstract void TrySetCanceled();
 785
 786        /// <summary>Returns cleanup as a task for dispatch paths that already operate on <see cref="Task"/>.</summary>
 787        [MethodImpl(MethodImplOptions.AggressiveInlining)]
 788        protected Task CleanupOnceAsTask()
 789        {
 2790            var cleanup = CleanupOnceAsync();
 2791            return cleanup.IsCompletedSuccessfully ? Task.CompletedTask : cleanup.AsTask();
 792        }
 793
 794        private Task TimeoutAsync()
 2795            => DispatchSerialAsync(
 2796                0,
 2797                static (subscription, _) => subscription.TimeoutCoreAsync());
 798
 799        private Task TimeoutCoreAsync()
 800        {
 2801            if (CleanupStarted)
 2802                return Task.CompletedTask;
 803
 2804            if (!TryBeginTerminal())
 2805                return Task.CompletedTask;
 806
 2807            _owner._logger.LogWarning("Timed out waiting for response for correlationId {CorrelationId}.", CorrelationId
 2808            AsyncResponseDiagnostics.RecordWaiterTimeout("inmemory");
 809
 2810            var exception = new TimeoutException($"Timed out waiting for response for correlationId {CorrelationId}.");
 2811            AsyncResponseDiagnostics.SetError(_activity, "timeout", exception.Message);
 2812            SetTimeoutException(exception);
 2813            return CleanupOnceAsTask();
 814        }
 815    }
 816
 817    private sealed class Subscription<T> : SubscriptionBase where T : IAsyncResponsePayload
 818    {
 819        private readonly Func<T, ValueTask<bool>> _completionPredicate;
 820        private readonly ExecutionContext? _capturedContext;
 2821        private readonly TaskCompletionSource<T> _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
 822
 823        /// <summary>Creates a typed in-memory waiter subscription.</summary>
 824        public Subscription(
 825            InMemoryAsyncResponseChannel owner,
 826            string correlationId,
 827            TimeSpan timeout,
 828            Func<T, ValueTask<bool>> completionPredicate,
 829            Activity? activity,
 830            ExecutionContext? capturedContext)
 2831            : base(owner, correlationId, timeout, activity)
 832        {
 2833            _completionPredicate = completionPredicate;
 2834            _capturedContext = capturedContext;
 2835        }
 836
 2837        public Task<T> ResponseTask => _tcs.Task;
 838
 839        /// <inheritdoc />
 840        public override Task DispatchResponseAsync(object? response)
 2841            => DispatchSerialAsync(
 2842                response,
 2843                static (subscription, state) => ((Subscription<T>)subscription).DispatchResponseUnserializedAsync(state)
 844
 845        private Task DispatchResponseUnserializedAsync(object? response)
 846        {
 2847            if (CleanupStarted)
 2848                return Task.CompletedTask;
 849
 850            // Restore the waiter's subscribe-time ambient context (trace, principal, …) so the
 851            // completion predicate and any logging run under it, even when the response is delivered
 852            // on a foreign thread such as a broker ingress callback.
 2853            if (_capturedContext is null)
 2854                return DispatchResponseCoreAsync(response);
 855
 2856            Task? dispatch = null;
 2857            ExecutionContext.Run(_capturedContext, _ => dispatch = DispatchResponseCoreAsync(response), null);
 2858            return dispatch!;
 859        }
 860
 861        /// <inheritdoc />
 862        public override Task DispatchRawJsonResponseAsync(RawJsonResponse response)
 2863            => DispatchSerialAsync(
 2864                response,
 2865                static (subscription, state) => ((Subscription<T>)subscription).DispatchRawJsonResponseUnserializedAsync
 866
 867        private Task DispatchRawJsonResponseUnserializedAsync(RawJsonResponse response)
 868        {
 2869            if (CleanupStarted)
 2870                return Task.CompletedTask;
 871
 2872            if (_capturedContext is null)
 2873                return DispatchRawJsonResponseCoreAsync(response);
 874
 2875            Task? dispatch = null;
 2876            ExecutionContext.Run(_capturedContext, _ => dispatch = DispatchRawJsonResponseCoreAsync(response), null);
 2877            return dispatch!;
 878        }
 879
 880        private Task DispatchResponseCoreAsync(object? response)
 881        {
 882            try
 883            {
 2884                var payload = response is T typed
 2885                    ? typed
 2886                    : response.As<T>();
 887
 2888                var completion = _completionPredicate(payload);
 2889                if (!completion.IsCompletedSuccessfully)
 2890                    return AwaitCompletionPredicateAsync(completion, payload);
 891
 2892                var finished = completion.Result;
 2893                if (!finished || !TryBeginTerminal())
 2894                    return Task.CompletedTask;
 895
 2896                _tcs.TrySetResult(payload);
 2897                return CleanupOnceAsTask();
 898            }
 2899            catch (Exception ex)
 900            {
 2901                if (!TryBeginTerminal())
 2902                    return Task.CompletedTask;
 903
 2904                AsyncResponseDiagnostics.SetError(WaitActivity, ex);
 2905                _tcs.TrySetException(ex);
 2906                return CleanupOnceAsTask();
 907            }
 2908        }
 909
 910        private Task DispatchRawJsonResponseCoreAsync(RawJsonResponse response)
 911        {
 912            try
 913            {
 2914                return DispatchPayloadAsync(response.Deserialize<T>()!);
 915            }
 2916            catch (Exception ex)
 917            {
 2918                return FaultAsync(ex);
 919            }
 2920        }
 921
 922        // Raw ingress has to materialize JSON before it can run the same completion semantics as
 923        // the typed path. Keep this separate so typed publishers stay on the shorter inline path.
 924        private Task DispatchPayloadAsync(T payload)
 925        {
 926            try
 927            {
 2928                var completion = _completionPredicate(payload);
 2929                if (!completion.IsCompletedSuccessfully)
 2930                    return AwaitCompletionPredicateAsync(completion, payload);
 931
 2932                var finished = completion.Result;
 2933                if (!finished || !TryBeginTerminal())
 2934                    return Task.CompletedTask;
 935
 2936                _tcs.TrySetResult(payload);
 2937                return CleanupOnceAsTask();
 938            }
 2939            catch (Exception ex)
 940            {
 2941                return FaultAsync(ex);
 942            }
 2943        }
 944
 945        private Task FaultAsync(Exception exception)
 946        {
 2947            if (!TryBeginTerminal())
 2948                return Task.CompletedTask;
 949
 2950            AsyncResponseDiagnostics.SetError(WaitActivity, exception);
 2951            _tcs.TrySetException(exception);
 2952            return CleanupOnceAsTask();
 953        }
 954
 955        private async Task AwaitCompletionPredicateAsync(ValueTask<bool> completion, T payload)
 956        {
 957            try
 958            {
 2959                var finished = await completion.ConfigureAwait(false);
 2960                if (!finished || !TryBeginTerminal())
 2961                    return;
 962
 2963                _tcs.TrySetResult(payload);
 2964                await CleanupOnceAsync().ConfigureAwait(false);
 2965            }
 2966            catch (Exception ex)
 967            {
 2968                if (!TryBeginTerminal())
 2969                    return;
 970
 2971                AsyncResponseDiagnostics.SetError(WaitActivity, ex);
 2972                _tcs.TrySetException(ex);
 2973                await CleanupOnceAsync().ConfigureAwait(false);
 974            }
 2975        }
 976
 977        /// <inheritdoc />
 978        protected override void SetTimeoutException(Exception exception)
 2979            => _tcs.TrySetException(exception);
 980
 981        /// <inheritdoc />
 982        public override void TrySetException(Exception exception)
 2983            => _tcs.TrySetException(exception);
 984
 985        /// <inheritdoc />
 986        public override void TrySetCanceled()
 2987            => _tcs.TrySetCanceled();
 988    }
 989}
 990
 991internal sealed class InMemoryAsyncResponseWaiter<T>(
 992    Task<T> _responseTask,
 993    Func<ValueTask> _cleanupAsync) : IAsyncResponseWaiter<T> where T : IAsyncResponsePayload
 994{
 995    public Task<T> ResponseTask => _responseTask;
 996
 997    /// <inheritdoc />
 998    public ValueTask DisposeAsync()
 999        => _cleanupAsync();
 1000}

Methods/Properties

.ctor(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory, AsyncResponse.IRecoveryStateStore, Microsoft.Extensions.Options.IOptions<AsyncResponse.InMemoryAsyncResponseOptions>, AsyncResponse.AsyncResponseContextPropagation, Microsoft.Extensions.Logging.ILogger<AsyncResponse.InMemoryAsyncResponseChannel>)
CreateResponseWaiter()
SetResponse<T>(T, string, System.Threading.CancellationToken)
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponse(object, string, System.Threading.CancellationToken)
AsyncResponse.IRawAsyncResponsePublisher.SetRawResponseJson(string, string, System.Threading.CancellationToken)
SetResponseCore()
SetRawResponseJsonCore()
SetException()
CountActiveSubscribersAsync(string, System.Threading.CancellationToken)
AddSubscription(string, AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionBase)
SnapshotSubscribers(string)
DispatchResponsesAsync(AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionSnapshot, object)
DispatchRawJsonResponsesAsync(AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionSnapshot, AsyncResponse.RawJsonResponse)
DispatchExceptionsAsync(AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionSnapshot, System.Exception)
DispatchManyAsync<TState>(AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionBase[], System.Func<AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionBase, TState, System.Threading.Tasks.Task>, TState)
RemoveSubscription(string, System.Guid)
ChannelName(string)
.ctor()
get_Count()
TryAdd(AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionBase)
Remove(System.Guid)
Snapshot()
.ctor(AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionBase, AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionBase[])
get_Count()
ForSingle(AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionBase)
ForMany(AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionBase[])
.ctor(AsyncResponse.InMemoryAsyncResponseChannel, string, System.TimeSpan, System.Diagnostics.Activity)
get_WaitActivity()
get_CleanupStarted()
ArmTimeout()
DispatchExceptionAsync(System.Exception)
DispatchExceptionCoreAsync(System.Exception)
DispatchSerialAsync<TState>(TState, System.Func<AsyncResponse.InMemoryAsyncResponseChannel.SubscriptionBase, TState, System.Threading.Tasks.Task>)
WaitAndDispatchAsync()
ReleaseAfterDispatchAsync()
ReleaseDispatch()
CleanupOnceAsync()
DisposeCleanupAsync()
StartCleanupAsync()
TryBeginTerminal()
CleanupOnceAsTask()
TimeoutAsync()
TimeoutCoreAsync()
.ctor(AsyncResponse.InMemoryAsyncResponseChannel, string, System.TimeSpan, System.Func<T, System.Threading.Tasks.ValueTask<bool>>, System.Diagnostics.Activity, System.Threading.ExecutionContext)
get_ResponseTask()
DispatchResponseAsync(object)
DispatchResponseUnserializedAsync(object)
DispatchRawJsonResponseAsync(AsyncResponse.RawJsonResponse)
DispatchRawJsonResponseUnserializedAsync(AsyncResponse.RawJsonResponse)
DispatchResponseCoreAsync(object)
DispatchRawJsonResponseCoreAsync(AsyncResponse.RawJsonResponse)
DispatchPayloadAsync(T)
FaultAsync(System.Exception)
AwaitCompletionPredicateAsync()
SetTimeoutException(System.Exception)
TrySetException(System.Exception)
TrySetCanceled()