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

Information
Class: AsyncResponse.Channels.Redis.RedisAsyncResponseChannel
Assembly: AsyncResponse.Channels.Redis
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/Channels/AsyncResponse.Channels.Redis/RedisAsyncResponseChannel.cs
Line coverage
100%
Covered lines: 390
Uncovered lines: 0
Coverable lines: 390
Total lines: 796
Line coverage: 100%
Branch coverage
96%
Covered branches: 135
Total branches: 140
Branch coverage: 96.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/Channels/AsyncResponse.Channels.Redis/RedisAsyncResponseChannel.cs

#LineLine coverage
 1using Microsoft.Extensions.DependencyInjection;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using StackExchange.Redis;
 5using System.Buffers;
 6using System.Diagnostics;
 7using System.Text;
 8using System.Text.Json;
 9
 10namespace AsyncResponse.Channels.Redis;
 11
 12/// <summary>
 13/// Redis-backed response channel:
 14/// <list type="bullet">
 15/// <item><description>Publishes responses to Redis pub/sub channels keyed by correlation id.</description></item>
 16/// <item><description>Subscribes waiters to those channels with per-channel serialized handling.</description></item>
 17/// <item><description>Persists <see cref="RecoveryState"/> so responses arriving after the waiter
 18/// died (e.g. a redeploy) are routed through the lost-subscriber dispatcher, which asks the payload's
 19/// ShouldResumeOnRecovery and invokes the resume or failure callback.</description></item>
 20/// </list>
 21/// </summary>
 22internal sealed class RedisAsyncResponseChannel : IAsyncResponsePublisher, IRawAsyncResponsePublisher, IRecoverableAsync
 23{
 24
 25    private readonly ISubscriber _subscriber;
 26    private readonly IRedisChannelSubscriber _channelSubscriber;
 27    private readonly IConnectionMultiplexer _multiplexer;
 28    private readonly IRecoveryStateStore _recoveryStateStore;
 29    private readonly AsyncResponseContextPropagation _propagation;
 30    private readonly LostSubscriberCallbackDispatcher _lostSubscriberDispatcher;
 31    private readonly RedisKeySchema _keys;
 32    private readonly RedisAsyncResponseOptions _options;
 33    private readonly ILogger<RedisAsyncResponseChannel> _logger;
 34
 35    private readonly SerialExecutorRegistry _executors;
 36
 37    /// <summary>Creates a Redis-backed async-response channel.</summary>
 338    public RedisAsyncResponseChannel(
 339        IServiceScopeFactory scopeFactory,
 340        IConnectionMultiplexer multiplexer,
 341        IRecoveryStateStore recoveryStateStore,
 342        IOptions<RedisAsyncResponseOptions> options,
 343        AsyncResponseContextPropagation propagation,
 344        ILogger<RedisAsyncResponseChannel> logger,
 345        IRedisChannelSubscriber? channelSubscriber = null)
 46    {
 347        _subscriber = multiplexer.GetSubscriber();
 348        _channelSubscriber = channelSubscriber ?? new RedisChannelMessageQueueSubscriber(_subscriber);
 349        _multiplexer = multiplexer;
 350        _recoveryStateStore = recoveryStateStore;
 351        _propagation = propagation;
 352        _options = options.Value;
 353        _options.ValidateShared(nameof(RedisAsyncResponseOptions));
 354        _keys = new RedisKeySchema(_options.KeyPrefix);
 355        _logger = logger;
 356        _lostSubscriberDispatcher = new LostSubscriberCallbackDispatcher(scopeFactory, propagation, logger);
 357        _executors = new SerialExecutorRegistry(logger);
 358    }
 59
 60    // ---------------------------------------------------------------------------------------
 61    // IAsyncResponseSubscriber / IRecoverableAsyncResponseSubscriber
 62
 63    /// <inheritdoc/>
 64    public Task<IAsyncResponseWaiter<T>> CreateResponseWaiter<T>(
 65        string correlationId,
 66        Func<T, ValueTask<bool>>? completionPredicate = null,
 67        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 368        => CreateResponseWaiterCore(
 369            correlationId,
 370            resumeCallback: null,
 371            failureCallback: null,
 372            completionPredicate,
 373            timeout);
 74
 75    /// <inheritdoc/>
 76    public Task<IAsyncResponseWaiter<T>> CreateRecoverableResponseWaiter<T>(
 77        string correlationId,
 78        ReflectionCallDto? resumeCallback = null,
 79        ReflectionCallDto? failureCallback = null,
 80        Func<T, ValueTask<bool>>? completionPredicate = null,
 81        TimeSpan? timeout = null) where T : IAsyncResponsePayload
 382        => CreateResponseWaiterCore(
 383            correlationId,
 384            resumeCallback,
 385            failureCallback,
 386            completionPredicate,
 387            timeout);
 88
 89    private async Task<IAsyncResponseWaiter<T>> CreateResponseWaiterCore<T>(
 90        string correlationId,
 91        ReflectionCallDto? resumeCallback,
 92        ReflectionCallDto? failureCallback,
 93        Func<T, ValueTask<bool>>? completionPredicate,
 94        TimeSpan? timeout) where T : IAsyncResponsePayload
 95    {
 396        if (string.IsNullOrWhiteSpace(correlationId))
 397            throw new ArgumentNullException(nameof(correlationId), "CorrelationId must not be empty or whitespace.");
 98
 99        // Recovery callbacks only make sense if the payload can say whether a late response should
 100        // resume or fail the flow. On this durable channel that decision is real (it survives a
 101        // redeploy), so require the override rather than letting the conservative default silently
 102        // route every recovered response to the failure callback. The in-memory channel, which
 103        // cannot recover across a process restart, is deliberately not subject to this check.
 3104        if ((resumeCallback is not null || failureCallback is not null)
 3105            && !AsyncResponsePayloadReflection.OverridesShouldResumeOnRecovery(typeof(T)))
 106        {
 3107            throw new InvalidOperationException(
 3108                $"Payload type '{typeof(T)}' registers lost-subscriber recovery callbacks on the Redis channel " +
 3109                $"but does not override {nameof(IAsyncResponsePayload)}.{nameof(IAsyncResponsePayload.ShouldResumeOnReco
 3110                "Override it to declare which responses resume the flow (return true) versus fail it (return false); " +
 3111                "the durable channel needs this to route a response that arrives after the waiter was lost.");
 112        }
 113
 114        // default: first envelope completes the wait
 3115        completionPredicate ??= _ => new ValueTask<bool>(true);
 116
 117        // Default timeout aligned with the recovery-state expiry: an infinite wait is never
 118        // meaningful, because once the recovery state expires the correlation id has no recovery
 119        // anyway. Timing out routes the flow through its normal failure handling instead of
 120        // leaving it stuck forever.
 3121        timeout ??= _options.DefaultTimeout ?? _options.RecoveryStateExpiry;
 122        // BEFORE any side effect: an unsupported resolved timeout (non-positive, or past the
 123        // ~49.7-day BCL timer ceiling) used to throw only at timer arming — after the
 124        // subscription and recovery state existed, leaking both — and zero used to slip through
 125        // on some channels entirely, insta-timing-out a fully registered waiter.
 3126        AsyncResponseChannelOptions.EnsureWaiterTimeoutSupported(timeout.Value);
 127
 3128        var storedCorrelationId = correlationId;
 129        // Capture the subscribe-time ExecutionContext so app AsyncLocals (trace, principal, logging
 130        // scope) flow into the message handler, which runs on a foreign Redis subscriber thread.
 3131        var capturedContext = ExecutionContext.Capture();
 3132        var channel = _keys.Channel(correlationId);
 133
 3134        var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.wait", correlationId: correlationId);
 3135        activity?.SetTag("asyncresponse.channel", "redis");
 3136        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 3137        activity?.SetTag("asyncresponse.timeout_seconds", timeout.Value.TotalSeconds);
 138
 3139        if (_logger.IsEnabled(LogLevel.Debug))
 3140            _logger.LogDebug("Waiting for response on correlationId {CorrelationId} with timeout {Timeout}.", correlatio
 141
 3142        var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
 3143        var registrationId = Guid.NewGuid();
 144
 145        // Single-use cancellation token implementing the timeout. The timer is armed only
 146        // after subscribe + recovery-state save succeeds, but the callback is registered before
 147        // subscribing so a very fast terminal message can still clean up safely.
 3148        var cancellationTokenSource = new CancellationTokenSource();
 3149        CancellationTokenRegistration timeoutRegistration = default;
 3150        IRedisChannelSubscription? subscription = null;
 3151        var executorRegistered = false;
 152
 153        // -------------------------------------------------------------------------
 154        // Local: CleanupOnceAsync
 155        // Ensures unsubscribe, recovery-state delete, timeout disposal, and executor cleanup
 156        // happen once no matter whether completion, timeout, or waiter disposal got there first.
 3157        int cleanupStarted = 0;
 3158        var cleanupGate = new object();
 3159        Task? cleanupTask = null;
 160
 161        // Task-latched so EVERY caller completes only when the one real cleanup has finished —
 162        // the previous fire-once int latch let a second caller (a disposing waiter racing the
 163        // timeout) return before the task was settled.
 164        ValueTask CleanupOnceAsync()
 165        {
 166            Task task;
 3167            lock (cleanupGate)
 168            {
 3169                task = cleanupTask ??= CleanupCoreAsync();
 3170            }
 171
 3172            return task.IsCompletedSuccessfully ? ValueTask.CompletedTask : new ValueTask(task);
 173        }
 174
 175        // Dispose-path cleanup: DRAINS the per-channel serial executor before settling. A delivery
 176        // may be mid Until-predicate holding a claimed terminal message; the marker work item
 177        // completes only after that in-flight item finished, so by the time cleanup cancels, the
 178        // task is either settled by the delivery or genuinely undelivered — never a cancellation
 179        // stealing a consumed response. Must NOT be called from dispatch code (which runs ON the
 180        // executor): the dispatch-triggered cleanup uses CleanupOnceAsync directly, its task
 181        // already settled.
 182        //
 183        // The drain is bounded by DisposalDrainTimeout — one budget covering marker ADMISSION too
 184        // (a full bounded queue behind a wedged item blocks the enqueue itself). A lapsed budget
 185        // must not fall back to the cleanup's cancel: the wedged delivery holds a message already
 186        // consumed from the stream, and "canceled" would tell a re-attaching caller nothing was
 187        // delivered. It faults the task with the explicit indeterminate contract instead, routing
 188        // durable flows to a fresh idempotent restart. A tombstone-suppressed enqueue is the
 189        // opposite case — the retired executor finished everything it ever admitted, so nothing
 190        // is in flight and the plain cancel is truthful.
 191        async ValueTask DrainThenCleanupAsync()
 192        {
 3193            if (Volatile.Read(ref cleanupStarted) == 0 && executorRegistered)
 194            {
 3195                var drainTimeout = _options.DisposalDrainTimeout;
 3196                var drained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 197                try
 198                {
 3199                    using var budget = new CancellationTokenSource(drainTimeout);
 3200                    var accepted = await _executors.EnqueueAsync(channel.ToString()!, () =>
 3201                    {
 3202                        drained.TrySetResult();
 3203                        return Task.CompletedTask;
 3204                    }, budget.Token).ConfigureAwait(false);
 3205                    if (accepted)
 3206                        await drained.Task.WaitAsync(budget.Token).ConfigureAwait(false);
 3207                }
 1208                catch (Exception drainEx)
 209                {
 210                    // Budget lapse — or an unforeseen drain failure: either way the marker never
 211                    // ran, so an in-flight delivery cannot be ruled out (only accepted=false
 212                    // proves the executor finished everything). Settlement unproven means the
 213                    // cleanup's cancel below would be a false "nothing was delivered" — fault
 214                    // with the explicit indeterminate contract instead. A TrySetResult from the
 215                    // late-finishing dispatch loses against this and is dropped; its cleanup
 216                    // call is a no-op behind the latch.
 1217                    _logger.LogWarning(
 1218                        "Disposal drain for correlationId {CorrelationId} did not prove settlement within {DrainTimeout}
 1219                        correlationId, drainTimeout);
 1220                    AsyncResponseDiagnostics.SetError(activity, "indeterminate_delivery", "Disposal drain did not prove 
 1221                    if (drainEx is not OperationCanceledException)
 1222                        _logger.LogDebug(drainEx, "Dispatch drain failed for channel {Channel}.", channel.ToString()!);
 1223                    tcs.TrySetException(new AsyncResponseIndeterminateDeliveryException(correlationId, drainTimeout));
 1224                }
 3225            }
 226
 3227            await CleanupOnceAsync().ConfigureAwait(false);
 3228        }
 229
 230        async Task CleanupCoreAsync()
 231        {
 3232            Interlocked.Exchange(ref cleanupStarted, 1);
 233
 234            // A waiter disposed before any terminal signal must not leave ResponseTask pending
 235            // forever for callers that hold it directly — the timeout dies with this cleanup, so
 236            // nothing else could ever complete the task. Cancellation is a no-op after a normal
 237            // completion, timeout, or fault (and after a delivery drained by DrainThenCleanupAsync).
 3238            tcs.TrySetCanceled();
 239
 240            try
 241            {
 242                try
 243                {
 244                    // Delete the recovery state BEFORE unsubscribing. In the reverse order a publish
 245                    // landing in the window sees "no subscriber, state present" and fires a spurious
 246                    // recovery callback for a wait that already reached a terminal state. In this
 247                    // order the window shows a subscriber that drops the message — a late or duplicate
 248                    // terminal message is droppable; a resurrected recovery callback is not.
 3249                    await _recoveryStateStore.TryDeleteAsync(correlationId, registrationId).ConfigureAwait(false);
 3250                }
 3251                catch (Exception ex)
 252                {
 253                    // Best-effort: the state expires on its own, and a transient store failure must
 254                    // not skip the unsubscribe and executor teardown below.
 3255                    _logger.LogError(ex, "Failed to delete recovery state for correlationId {CorrelationId}.", correlati
 3256                }
 257
 258                try
 259                {
 260                    // Bounded like the drain: this latched core is what a disposing waiter awaits
 261                    // when terminal delivery started cleanup first, so an unbudgeted unsubscribe
 262                    // would let a wedged client library hold DisposeAsync hostage past
 263                    // DisposalDrainTimeout. The quiet wrapper logs its own failure — including
 264                    // one that completes AFTER this wait was abandoned, which previously died as
 265                    // a TaskScheduler.UnobservedTaskException nobody logged.
 3266                    if (subscription is not null)
 3267                        await UnsubscribeQuietlyAsync(subscription).WaitAsync(_options.DisposalDrainTimeout).ConfigureAw
 3268                }
 1269                catch (TimeoutException)
 270                {
 1271                    _logger.LogError(
 1272                        "Unsubscribe for channel {Channel} did not finish within {DisposalDrainTimeout}; abandoning the 
 1273                        channel.ToString()!, _options.DisposalDrainTimeout);
 1274                }
 275            }
 276            finally
 277            {
 278                // Purely local teardown runs no matter which network call above failed — the
 279                // cleanup latch is already set, so anything skipped here would leak until process
 280                // exit.
 3281                if (executorRegistered)
 3282                    _executors.OnSubscriptionRetired(channel.ToString()!);
 283
 284                // Schedule the disposal on the thread pool; do not await directly to prevent
 285                // deadlocks with work currently running on the executor.
 3286                _ = Task.Run(async () =>
 3287                {
 3288                    try
 3289                    {
 3290                        await _executors.RemoveAsync(channel.ToString()!).ConfigureAwait(false);
 3291                    }
 1292                    catch (Exception ex)
 3293                    {
 1294                        _logger.LogError(ex, "Failed to retire the executor for channel {Channel}.", channel.ToString()!
 1295                    }
 3296                });
 297
 3298                await timeoutRegistration.DisposeAsync().ConfigureAwait(false);
 3299                cancellationTokenSource.Dispose();
 3300                activity?.Dispose();
 301            }
 302        }
 303
 304        // Never faults: the unsubscribe outcome is logged HERE, so a teardown outliving the
 305        // bounded wait above still records its failure instead of surfacing as an unobserved
 306        // task exception.
 307        async Task UnsubscribeQuietlyAsync(IRedisChannelSubscription liveSubscription)
 308        {
 309            try
 310            {
 3311                await liveSubscription.DisposeAsync().ConfigureAwait(false);
 3312                _logger.LogDebug("Unsubscribed from channel {Channel}.", channel.ToString()!);
 3313            }
 3314            catch (Exception ex)
 315            {
 3316                _logger.LogError(ex, "Error during unsubscribe-once for channel {Channel}.", channel.ToString()!);
 3317            }
 3318        }
 319
 320        // -------------------------------------------------------------------------
 321        // Local: ProcessRedisMessageAsync
 322        // Deserializes and handles a single incoming envelope, completes the TCS when terminal.
 323        async Task ProcessRedisMessageAsync(RedisChannel messageChannel, RedisValue messageValue)
 324        {
 3325            _logger.LogDebug("Received message on channel {Channel}.", messageChannel.ToString()!);
 326
 3327            bool finished = false;
 328            try
 329            {
 3330                var envelope = JsonSerializer.Deserialize(messageValue.ToString(), AsyncResponseEnvelopeJson.TypeInfo<T>
 331
 3332                if (envelope == null)
 333                {
 3334                    _logger.LogError("Failed to deserialize envelope for correlationId {CorrelationId}.", correlationId)
 335
 2336                    finished = true;
 2337                    var deserializationError = new JsonException($"Failed to deserialize envelope for correlationId {cor
 2338                    AsyncResponseDiagnostics.SetError(activity, "deserialize_failure", deserializationError.Message);
 2339                    if (!tcs.TrySetException(deserializationError))
 2340                        _logger.LogWarning(deserializationError, "TaskCompletionSource already completed for correlation
 341                }
 3342                else if (!AsyncResponseEnvelopeSchema.IsReadable(envelope.SchemaVersion))
 343                {
 3344                    finished = true;
 3345                    var schemaError = new InvalidOperationException(
 3346                        $"Response envelope for correlationId {correlationId} has schema version {envelope.SchemaVersion
 3347                        $"which this build does not support (current: {AsyncResponseEnvelopeSchema.Current}).");
 2348                    AsyncResponseDiagnostics.SetError(activity, "schema_mismatch", schemaError.Message);
 2349                    if (!tcs.TrySetException(schemaError))
 2350                        _logger.LogWarning(schemaError, "TaskCompletionSource already completed for correlationId {Corre
 351                }
 3352                else if (!envelope.Success)
 353                {
 3354                    finished = true;
 3355                    var remoteFailure = new Exception(envelope.ExceptionMessage ?? "Unknown error during asynchronous pr
 3356                    if (!string.IsNullOrEmpty(envelope.ExceptionStackTrace))
 357                    {
 358                        // Cap on receive too: the publish-side cap only bounds traces we emit, not what
 359                        // a remote we do not control can push at us.
 3360                        remoteFailure.Data["RemoteStackTrace"] = RemoteStackTrace.Cap(envelope.ExceptionStackTrace, _opt
 361                    }
 362
 3363                    _logger.LogWarning("Received error response for correlationId {CorrelationId}: {ErrorMessage}", corr
 3364                    AsyncResponseDiagnostics.SetError(activity, "remote_failure", remoteFailure.Message);
 3365                    if (!tcs.TrySetException(remoteFailure))
 3366                        _logger.LogWarning(remoteFailure, "TaskCompletionSource already completed for correlationId {Cor
 367                }
 368                else
 369                {
 3370                    if (_logger.IsEnabled(LogLevel.Debug))
 3371                        _logger.LogDebug("Received response for correlationId {CorrelationId}.", correlationId);
 372
 3373                    finished = await completionPredicate(envelope.Payload!).ConfigureAwait(false);
 374
 3375                    if (finished && !tcs.TrySetResult(envelope.Payload!))
 3376                        _logger.LogWarning("TaskCompletionSource already completed for correlationId {CorrelationId}.", 
 377                }
 3378            }
 3379            catch (Exception ex)
 380            {
 3381                _logger.LogError(ex, "Error processing message on channel {Channel} for correlationId {CorrelationId}.",
 382
 2383                finished = true;
 2384                AsyncResponseDiagnostics.SetError(activity, ex);
 2385                if (!tcs.TrySetException(ex))
 2386                    _logger.LogWarning(ex, "TaskCompletionSource already completed for correlationId {CorrelationId}.", 
 3387            }
 388            finally
 389            {
 390                // Unsubscription also happens on dispose, but doing it immediately after the
 391                // terminal message releases resources sooner.
 3392                if (finished)
 3393                    await CleanupOnceAsync().ConfigureAwait(false);
 394            }
 395        }
 396
 397        // -------------------------------------------------------------------------
 398        // Local: HandleMessageAsync
 399        // Receives pub/sub messages from the async subscription and enqueues them on the
 400        // per-channel executor, awaiting admission so executor backpressure reaches the
 401        // subscription's message loop instead of blocking a Redis reader thread.
 402        Task HandleMessageAsync(RedisChannel messageChannel, RedisValue messageValue)
 403        {
 404            // The registry coordinates create/enqueue/retire under one lock, so the message is never
 405            // enqueued onto an executor that is concurrently being torn down (no lost messages) and a
 406            // correlation-id reused mid-drain never produces two live executors for one channel.
 3407            var enqueue = _executors.EnqueueAsync(
 3408                messageChannel.ToString()!,
 3409                () => ProcessUnderCapturedContextAsync(messageChannel, messageValue));
 3410            return enqueue.IsCompletedSuccessfully ? Task.CompletedTask : enqueue.AsTask();
 411        }
 412
 413        // -------------------------------------------------------------------------
 414        // Local: ProcessUnderCapturedContextAsync
 415        // Restores the waiter's subscribe-time ExecutionContext (app AsyncLocals: trace, principal,
 416        // logging scope) plus the correlation id before processing — the Redis subscriber callback
 417        // runs on a foreign thread-pool thread that never had them.
 418        Task ProcessUnderCapturedContextAsync(RedisChannel messageChannel, RedisValue messageValue)
 419        {
 420            async Task ProcessAsync()
 421            {
 3422                using var correlationScope = AsyncResponseContext.PushCorrelationId(storedCorrelationId);
 3423                await ProcessRedisMessageAsync(messageChannel, messageValue).ConfigureAwait(false);
 3424            }
 425
 3426            if (capturedContext is null)
 3427                return ProcessAsync();
 428
 3429            Task? task = null;
 3430            ExecutionContext.Run(capturedContext, _ => task = ProcessAsync(), null);
 3431            return task!;
 432        }
 433
 3434        timeoutRegistration = cancellationTokenSource.Token.Register(() =>
 3435        {
 3436            _ = Task.Run(async () =>
 3437            {
 3438                _logger.LogWarning("Timed out waiting for response for correlationId {CorrelationId}.", correlationId);
 3439                AsyncResponseDiagnostics.SetError(activity, "timeout", $"Timed out waiting for response for correlationI
 3440                AsyncResponseDiagnostics.RecordWaiterTimeout("redis");
 3441                tcs.TrySetException(new TimeoutException($"Timed out waiting for response for correlationId {correlation
 3442                await DrainThenCleanupAsync().ConfigureAwait(false);
 3443            });
 3444        });
 445
 446        try
 447        {
 448            // Register the executor channel BEFORE the server-side SUBSCRIBE completes: the
 449            // subscriber attaches its message pump inside SubscribeAsync, so deliveries can start
 450            // before it returns, and on a correlation id reused within the tombstone lifetime the
 451            // registry would silently drop them as retirement stragglers until this registration
 452            // is visible. The subscribe-failure path below retires it again.
 3453            _executors.OnSubscriptionRegistered(channel.ToString()!);
 3454            executorRegistered = true;
 3455            subscription = await _channelSubscriber.SubscribeAsync(channel, HandleMessageAsync).ConfigureAwait(false);
 3456            var recoveryState = new RecoveryState
 3457            {
 3458                RegistrationId = registrationId,
 3459                ResumeCallback = resumeCallback,
 3460                FailureCallback = failureCallback,
 3461                CorrelationId = correlationId,
 3462                PayloadTypeFullName = typeof(T).FullName,
 3463                RegisteredAtUtc = DateTime.UtcNow,
 3464                Context = _propagation.Capture()
 3465            };
 3466            await _recoveryStateStore.SaveAsync(correlationId, recoveryState, _options.RecoveryStateExpiry).ConfigureAwa
 3467            _logger.LogDebug("Subscribed to channel {Channel} for correlationId {CorrelationId}.", channel.ToString()!, 
 3468        }
 3469        catch (Exception ex)
 470        {
 3471            _logger.LogError(ex, "Failed to subscribe to channel {Channel} for correlationId {CorrelationId}.", channel.
 2472            AsyncResponseDiagnostics.SetError(activity, "subscribe_failure", ex.Message);
 2473            await DrainThenCleanupAsync().ConfigureAwait(false);
 474
 475            // Rethrow instead of returning a pre-faulted waiter: the builder's contract is that
 476            // the trigger runs only once the subscription AND recovery state exist. A returned
 477            // waiter would still let the trigger fire the remote operation with no registration
 478            // left to receive (or recover) its response. Cleanup leaves nothing behind and cancels
 479            // the response task rather than faulting it, so no unobserved fault lingers.
 3480            throw;
 481        }
 482
 483        try
 484        {
 3485            if (Volatile.Read(ref cleanupStarted) == 0)
 3486                cancellationTokenSource.CancelAfter(timeout.Value);
 3487        }
 1488        catch (ObjectDisposedException)
 489        {
 490            // A response completed and cleaned up between the check and CancelAfter.
 1491        }
 492
 3493        return new RedisAsyncResponseWaiter<T>(tcs.Task, DrainThenCleanupAsync);
 3494    }
 495
 496    // ---------------------------------------------------------------------------------------
 497    // IAsyncResponsePublisher
 498
 499    /// <inheritdoc/>
 500    public Task SetResponse<T>(T response, string correlationId, CancellationToken cancellationToken = default) where T 
 3501        => SetResponseCore(response, correlationId, cancellationToken);
 502
 503    Task IRawAsyncResponsePublisher.SetRawResponse(object? response, string correlationId, CancellationToken cancellatio
 3504        => SetResponseCore(response, correlationId, cancellationToken);
 505
 506    Task IRawAsyncResponsePublisher.SetRawResponseJson(string responseJson, string correlationId, CancellationToken canc
 3507        => SetRawResponseJsonCore(responseJson, correlationId, cancellationToken);
 508
 509    // Intentionally duplicated with SetRawResponseJsonCore: this publish method is a latency hot
 510    // path, and earlier shared helper/delegate refactors regressed throughput in benchmarks.
 511    // Keep the typed Redis path inline unless a benchmark run proves a refactor is free.
 512    private async Task SetResponseCore<T>(T response, string correlationId, CancellationToken cancellationToken)
 513    {
 3514        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_response", ActivityKind.Producer)
 3515        activity?.SetTag("asyncresponse.channel", "redis");
 3516        AsyncResponseDiagnostics.SetPayloadType(activity, typeof(T));
 517
 518        // When no correlation id is provided, fall back to the ambient context.
 3519        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 520
 3521        if (string.IsNullOrWhiteSpace(correlationId))
 522        {
 3523            _logger.LogWarning("CorrelationId is null; cannot publish the response.");
 2524            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 3525            return;
 526        }
 527
 3528        var channel = _keys.Channel(correlationId);
 529        try
 530        {
 3531            var envelope = new AsyncResponseEnvelope<T>
 3532            {
 3533                Success = true,
 3534                Payload = response
 3535            };
 3536            var json = AsyncResponseEnvelopeJson.Serialize(envelope);
 3537            long numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false);
 3538            activity?.SetTag("asyncresponse.subscribers", numSubscribers);
 539
 3540            if (numSubscribers == 0)
 541            {
 542                // Nobody was listening (the waiter died, e.g. with a redeploy): hand the response
 543                // over to the lost-subscriber dispatcher, which asks the payload whether to resume
 544                // the flow or fail it, and invokes the matching callback.
 3545                var dispatchResult = await _lostSubscriberDispatcher
 3546                    .DispatchLostResponses(
 3547                        _recoveryStateStore,
 3548                        correlationId,
 3549                        response,
 3550                        channel.ToString()!,
 3551                        cancellationToken,
 3552                        hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 3553                    .ConfigureAwait(false);
 3554                if (dispatchResult.RetryLive)
 555                {
 556                    // A waiter subscribed between the publish and the recovery-state read —
 557                    // re-publish live instead of consuming its registration; only a second miss
 558                    // consumes it.
 3559                    numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false);
 2560                    activity?.SetTag("asyncresponse.subscribers", numSubscribers);
 2561                    if (numSubscribers > 0)
 2562                        return;
 563
 2564                    dispatchResult = await _lostSubscriberDispatcher
 2565                        .DispatchLostResponses(_recoveryStateStore, correlationId, response, channel.ToString()!, cancel
 2566                        .ConfigureAwait(false);
 567                }
 568
 3569                AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.ShouldResume);
 3570                AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.ShouldResume, dispatchResult.Ca
 3571                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 572
 3573                await _executors.RemoveAsync(channel.ToString()!).ConfigureAwait(false);
 574            }
 575            else
 576            {
 3577                if (_logger.IsEnabled(LogLevel.Debug))
 3578                    _logger.LogDebug("Published response for correlationId {CorrelationId} on channel {Channel}. Payload
 579            }
 3580        }
 3581        catch (Exception ex)
 582        {
 3583            _logger.LogError(ex, "Failed to publish response for correlationId {CorrelationId} on channel {Channel}.", c
 2584            AsyncResponseDiagnostics.SetError(activity, ex);
 3585            throw;
 586        }
 3587    }
 588
 589    // Intentionally duplicated with SetResponseCore: raw ingress uses pre-serialized payload JSON
 590    // and a different lost-subscriber materialization path, so avoiding shared indirection matters.
 591    private async Task SetRawResponseJsonCore(string responseJson, string correlationId, CancellationToken cancellationT
 592    {
 3593        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.ingress.raw_response", ActivityKind.P
 3594        activity?.SetTag("asyncresponse.channel", "redis");
 595
 3596        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 597
 3598        if (string.IsNullOrWhiteSpace(correlationId))
 599        {
 3600            _logger.LogWarning("CorrelationId is null; cannot publish the raw response.");
 2601            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 3602            return;
 603        }
 604
 3605        var channel = _keys.Channel(correlationId);
 606        try
 607        {
 3608            var json = SerializeRawSuccessEnvelope(responseJson);
 3609            long numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false);
 3610            activity?.SetTag("asyncresponse.subscribers", numSubscribers);
 611
 3612            if (numSubscribers == 0)
 613            {
 3614                var response = new RawJsonResponse(responseJson).DeserializeUntyped();
 615
 2616                var dispatchResult = await _lostSubscriberDispatcher
 2617                    .DispatchLostResponses(
 2618                        _recoveryStateStore,
 2619                        correlationId,
 2620                        response,
 2621                        channel.ToString()!,
 2622                        cancellationToken,
 3623                        hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 2624                    .ConfigureAwait(false);
 2625                if (dispatchResult.RetryLive)
 626                {
 627                    // A waiter subscribed between the publish and the recovery-state read —
 628                    // re-publish live instead of consuming its registration; only a second miss
 629                    // consumes it.
 2630                    numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false);
 2631                    activity?.SetTag("asyncresponse.subscribers", numSubscribers);
 2632                    if (numSubscribers > 0)
 2633                        return;
 634
 2635                    dispatchResult = await _lostSubscriberDispatcher
 2636                        .DispatchLostResponses(_recoveryStateStore, correlationId, response, channel.ToString()!, cancel
 2637                        .ConfigureAwait(false);
 638                }
 639
 2640                AsyncResponseDiagnostics.SetLostSubscriberRoute(activity, dispatchResult.ShouldResume);
 2641                AsyncResponseDiagnostics.RecordLostSubscriber("response", dispatchResult.ShouldResume, dispatchResult.Ca
 2642                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 643
 2644                await _executors.RemoveAsync(channel.ToString()!).ConfigureAwait(false);
 3645            }
 646            else
 647            {
 3648                if (_logger.IsEnabled(LogLevel.Debug))
 3649                    _logger.LogDebug("Published raw response for correlationId {CorrelationId} on channel {Channel}. Sub
 650            }
 3651        }
 3652        catch (Exception ex)
 653        {
 3654            _logger.LogError(ex, "Failed to publish raw response for correlationId {CorrelationId} on channel {Channel}.
 3655            AsyncResponseDiagnostics.SetError(activity, ex);
 3656            throw;
 657        }
 3658    }
 659
 660    /// <inheritdoc/>
 661    public async Task SetException(Exception exception, string correlationId, CancellationToken cancellationToken = defa
 662    {
 3663        ArgumentNullException.ThrowIfNull(exception);
 664
 3665        using var activity = AsyncResponseDiagnostics.StartActivity("asyncresponse.set_exception", ActivityKind.Producer
 3666        activity?.SetTag("asyncresponse.channel", "redis");
 3667        activity?.SetTag("asyncresponse.exception_type", exception.GetType().FullName ?? exception.GetType().Name);
 668
 3669        AsyncResponseDiagnostics.SetCorrelationId(activity, correlationId);
 670
 3671        if (string.IsNullOrWhiteSpace(correlationId))
 672        {
 3673            _logger.LogWarning("CorrelationId is null; cannot publish the exception. Exception: {ExceptionMessage}", exc
 2674            AsyncResponseDiagnostics.SetError(activity, "correlation_id_null", "CorrelationId is null; cannot publish th
 3675            return;
 676        }
 677
 3678        var channel = _keys.Channel(correlationId);
 679        try
 680        {
 3681            var envelope = new AsyncResponseEnvelope<object>
 3682            {
 3683                Success = false,
 3684                ExceptionMessage = exception.Message,
 3685                ExceptionStackTrace = RemoteStackTrace.ForWire(exception.StackTrace, _options.IncludeRemoteStackTrace, _
 3686                Payload = null
 3687            };
 3688            var json = AsyncResponseEnvelopeJson.Serialize(envelope);
 3689            long numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false);
 3690            activity?.SetTag("asyncresponse.subscribers", numSubscribers);
 691
 3692            if (numSubscribers == 0)
 693            {
 694                // Nobody was listening: exception envelopes always go to the failure callback.
 3695                var dispatchResult = await _lostSubscriberDispatcher
 3696                    .DispatchLostExceptions(
 3697                        _recoveryStateStore,
 3698                        correlationId,
 3699                        exception,
 3700                        channel.ToString()!,
 3701                        cancellationToken,
 3702                        hasLiveSubscriber: () => HasLiveSubscriberAsync(correlationId, cancellationToken))
 3703                    .ConfigureAwait(false);
 3704                if (dispatchResult.RetryLive)
 705                {
 706                    // A waiter subscribed between the publish and the recovery-state read —
 707                    // re-publish live instead of consuming its registration; only a second miss
 708                    // consumes it.
 3709                    numSubscribers = await _subscriber.PublishAsync(channel, json).ConfigureAwait(false);
 2710                    activity?.SetTag("asyncresponse.subscribers", numSubscribers);
 2711                    if (numSubscribers > 0)
 2712                        return;
 713
 2714                    dispatchResult = await _lostSubscriberDispatcher
 2715                        .DispatchLostExceptions(_recoveryStateStore, correlationId, exception, channel.ToString()!, canc
 2716                        .ConfigureAwait(false);
 717                }
 718
 3719                activity?.SetTag("asyncresponse.recovery.callback_invoked", dispatchResult.CallbackInvoked);
 3720                AsyncResponseDiagnostics.RecordLostSubscriber("exception", shouldResume: false, dispatchResult.CallbackI
 721
 3722                await _executors.RemoveAsync(channel.ToString()!).ConfigureAwait(false);
 723            }
 3724            else if (_logger.IsEnabled(LogLevel.Debug))
 725            {
 3726                _logger.LogDebug("Published exception response for correlationId {CorrelationId} on channel {Channel}. S
 727            }
 3728        }
 3729        catch (Exception ex)
 730        {
 3731            _logger.LogError(ex, "Failed to publish exception response for correlationId {CorrelationId} on channel {Cha
 3732            AsyncResponseDiagnostics.SetError(activity, ex);
 3733            throw;
 734        }
 3735    }
 736
 737    // ---------------------------------------------------------------------------------------
 738    // IActiveSubscriberProbe
 739
 740    /// <inheritdoc/>
 741    public ValueTask<long> CountActiveSubscribersAsync(string correlationId, CancellationToken cancellationToken = defau
 742    {
 3743        if (string.IsNullOrWhiteSpace(correlationId))
 3744            return new ValueTask<long>(0L);
 745
 3746        var channel = _keys.Channel(correlationId);
 747
 748        // Subscriptions live on whichever node the client subscribed through, so the live count is
 749        // the maximum reported across all connected endpoints.
 3750        long subscribers = 0;
 3751        foreach (var endPoint in _multiplexer.GetEndPoints())
 752        {
 3753            var server = _multiplexer.GetServer(endPoint);
 3754            if (!server.IsConnected)
 755                continue;
 756
 757            try
 758            {
 3759                subscribers = Math.Max(subscribers, server.SubscriptionSubscriberCount(channel));
 3760            }
 3761            catch (Exception ex)
 762            {
 3763                _logger.LogDebug(ex, "Failed to read subscriber count for channel {Channel}.", channel.ToString()!);
 3764            }
 765        }
 766
 3767        return new ValueTask<long>(subscribers);
 768    }
 769
 770    /// <summary>
 771    /// Re-probes waiter liveness for the lost-subscriber dispatcher's snapshot-race re-check,
 772    /// using the same PUBSUB NUMSUB-based probe the watchdog uses.
 773    /// </summary>
 774    private async ValueTask<bool> HasLiveSubscriberAsync(string correlationId, CancellationToken cancellationToken)
 3775        => await CountActiveSubscribersAsync(correlationId, cancellationToken).ConfigureAwait(false) > 0;
 776
 777    private static string SerializeRawSuccessEnvelope(string payloadJson)
 778    {
 3779        JsonSafety.ThrowIfClearlyNotJson(payloadJson);
 780
 3781        var buffer = new ArrayBufferWriter<byte>();
 3782        using (var writer = new Utf8JsonWriter(buffer))
 783        {
 3784            writer.WriteStartObject();
 3785            writer.WriteNumber("SchemaVersion", AsyncResponseEnvelopeSchema.Current);
 3786            writer.WriteBoolean("Success", true);
 3787            writer.WritePropertyName("Payload");
 3788            writer.WriteRawValue(payloadJson);
 3789            writer.WriteNull("ExceptionMessage");
 3790            writer.WriteNull("ExceptionStackTrace");
 3791            writer.WriteEndObject();
 3792        }
 793
 3794        return Encoding.UTF8.GetString(buffer.WrittenSpan);
 795    }
 796}

Methods/Properties

.ctor(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory, StackExchange.Redis.IConnectionMultiplexer, AsyncResponse.IRecoveryStateStore, Microsoft.Extensions.Options.IOptions<AsyncResponse.Channels.Redis.RedisAsyncResponseOptions>, AsyncResponse.AsyncResponseContextPropagation, Microsoft.Extensions.Logging.ILogger<AsyncResponse.Channels.Redis.RedisAsyncResponseChannel>, AsyncResponse.Channels.Redis.IRedisChannelSubscriber)
CreateResponseWaiter<T>(string, System.Func<T, System.Threading.Tasks.ValueTask<bool>>, System.Nullable<System.TimeSpan>)
CreateRecoverableResponseWaiter<T>(string, AsyncResponse.ReflectionCallDto, AsyncResponse.ReflectionCallDto, System.Func<T, System.Threading.Tasks.ValueTask<bool>>, System.Nullable<System.TimeSpan>)
CreateResponseWaiterCore()
CleanupOnceAsync()
DrainThenCleanupAsync()
CleanupCoreAsync()
<CreateResponseWaiterCore()
UnsubscribeQuietlyAsync()
ProcessRedisMessageAsync()
HandleMessageAsync()
ProcessAsync()
ProcessUnderCapturedContextAsync()
<CreateResponseWaiterCore()
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)
HasLiveSubscriberAsync()
SerializeRawSuccessEnvelope(string)