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

Information
Class: AsyncResponse.Transports.Redis.RedisStreamDelivery
Assembly: AsyncResponse.Transports.Redis
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/Transports/AsyncResponse.Transports.Redis/RedisMessageDispatcher.cs
Line coverage
100%
Covered lines: 8
Uncovered lines: 0
Coverable lines: 8
Total lines: 581
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/Transports/AsyncResponse.Transports.Redis/RedisMessageDispatcher.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using StackExchange.Redis;
 3using System.Diagnostics;
 4using System.Threading.Channels;
 5
 6namespace AsyncResponse.Transports.Redis;
 7
 8internal enum RedisSubscriberRole
 9{
 10    Worker,
 11    ResponseIngress
 12}
 13
 14internal enum RedisDispatchOutcome
 15{
 16    /// <summary>The entry was handled, ACKed, or dead-lettered. Counts as progress for the poll loop.</summary>
 17    Processed,
 18
 19    /// <summary>The entry could not be accepted right now (background queue full) and was left pending for retry.</summ
 20    Deferred
 21}
 22
 323internal sealed record RedisStreamDelivery(
 324    RedisKey Stream,
 325    RedisValue ConsumerGroup,
 326    RedisValue MessageId,
 327    string Payload,
 328    string? CorrelationId,
 329    int Attempt,
 330    StreamEntry Entry);
 31
 32internal abstract class RedisMessageDispatcher : IAsyncDisposable
 33{
 34    private readonly Func<RedisStreamDelivery, CancellationToken, Task> _handler;
 35    private readonly RedisSubscriberOptions _subscriberOptions;
 36    private readonly IRedisStreamDatabase _database;
 37    private readonly RedisTransportKeySchema _keys;
 38    private readonly string _stream;
 39    private readonly string _consumerGroup;
 40    private readonly RedisSubscriberRole _role;
 41
 42    /// <summary>Runs the RedisMessageDispatcher operation.</summary>
 43    protected RedisMessageDispatcher(
 44        Func<RedisStreamDelivery, CancellationToken, Task> handler,
 45        IRedisStreamDatabase database,
 46        RedisAsyncResponseTransportOptions transportOptions,
 47        RedisSubscriberOptions subscriberOptions,
 48        ILogger logger,
 49        RedisKey stream,
 50        RedisValue consumerGroup,
 51        RedisSubscriberRole role)
 52    {
 53        _handler = handler;
 54        _database = database;
 55        TransportOptions = transportOptions;
 56        _subscriberOptions = subscriberOptions;
 57        _keys = new RedisTransportKeySchema(transportOptions);
 58        Logger = logger;
 59        _stream = stream.ToString();
 60        _consumerGroup = consumerGroup.ToString();
 61        _role = role;
 62    }
 63
 64    protected RedisAsyncResponseTransportOptions TransportOptions { get; }
 65    protected ILogger Logger { get; }
 66
 67    protected int MaxDeliveryAttempts => _subscriberOptions.MaxDeliveryAttempts;
 68
 69    /// <summary>Creates the configured dispatcher.</summary>
 70    public static RedisMessageDispatcher Create(
 71        Func<RedisStreamDelivery, CancellationToken, Task> handler,
 72        IRedisStreamDatabase database,
 73        RedisAsyncResponseTransportOptions transportOptions,
 74        RedisSubscriberOptions subscriberOptions,
 75        ILogger logger,
 76        RedisKey stream,
 77        RedisValue consumerGroup,
 78        RedisSubscriberRole role)
 79    {
 80        ValidateOptions(transportOptions, subscriberOptions, role);
 81
 82        if (subscriberOptions.AckMode is RedisAckMode.AckAfterEnqueue)
 83        {
 84            return new QueuedRedisMessageDispatcher(
 85                handler,
 86                database,
 87                transportOptions,
 88                subscriberOptions,
 89                logger,
 90                stream,
 91                consumerGroup,
 92                role);
 93        }
 94
 95        return new AwaitingRedisMessageDispatcher(
 96            handler,
 97            database,
 98            transportOptions,
 99            subscriberOptions,
 100            logger,
 101            stream,
 102            consumerGroup,
 103            role);
 104    }
 105
 106    /// <summary>Validates the supplied options.</summary>
 107    public static void ValidateOptions(
 108        RedisAsyncResponseTransportOptions transportOptions,
 109        RedisSubscriberOptions subscriberOptions,
 110        RedisSubscriberRole role)
 111    {
 112        RedisTransportOptionsValidator.ValidateCommon(transportOptions);
 113
 114        var optionPath = role is RedisSubscriberRole.Worker
 115            ? $"{nameof(RedisAsyncResponseTransportOptions)}.{nameof(RedisAsyncResponseTransportOptions.WorkerSubscriber
 116            : $"{nameof(RedisAsyncResponseTransportOptions)}.{nameof(RedisAsyncResponseTransportOptions.ResponseSubscrib
 117
 118        if (subscriberOptions.BatchSize <= 0)
 119            throw new InvalidOperationException($"{optionPath}.{nameof(RedisSubscriberOptions.BatchSize)} must be positi
 120        if (subscriberOptions.EmptyPollDelay <= TimeSpan.Zero)
 121            throw new InvalidOperationException($"{optionPath}.{nameof(RedisSubscriberOptions.EmptyPollDelay)} must be p
 122        if (subscriberOptions.PendingMessageMinIdleTime <= TimeSpan.Zero)
 123            throw new InvalidOperationException($"{optionPath}.{nameof(RedisSubscriberOptions.PendingMessageMinIdleTime)
 124        if (subscriberOptions.PendingClaimInterval <= TimeSpan.Zero)
 125            throw new InvalidOperationException($"{optionPath}.{nameof(RedisSubscriberOptions.PendingClaimInterval)} mus
 126        if (subscriberOptions.PendingClaimBatchSize <= 0)
 127            throw new InvalidOperationException($"{optionPath}.{nameof(RedisSubscriberOptions.PendingClaimBatchSize)} mu
 128        if (subscriberOptions.MaxDeliveryAttempts < 0)
 129            throw new InvalidOperationException($"{optionPath}.{nameof(RedisSubscriberOptions.MaxDeliveryAttempts)} cann
 130
 131        switch (subscriberOptions.AckMode)
 132        {
 133            case RedisAckMode.AckAfterHandlerCompletes:
 134                return;
 135
 136            case RedisAckMode.AckAfterEnqueue:
 137                if (subscriberOptions.BackgroundWorkerCount <= 0)
 138                {
 139                    throw new InvalidOperationException(
 140                        $"{optionPath}.{nameof(RedisSubscriberOptions.BackgroundWorkerCount)} must be explicitly configu
 141                        $"when {nameof(RedisSubscriberOptions.AckMode)} is {nameof(RedisAckMode.AckAfterEnqueue)}.");
 142                }
 143
 144                if (subscriberOptions.BackgroundQueueCapacity <= 0)
 145                {
 146                    throw new InvalidOperationException(
 147                        $"{optionPath}.{nameof(RedisSubscriberOptions.BackgroundQueueCapacity)} must be explicitly confi
 148                        $"when {nameof(RedisSubscriberOptions.AckMode)} is {nameof(RedisAckMode.AckAfterEnqueue)}.");
 149                }
 150
 151                if (subscriberOptions.BackgroundDrainTimeout <= TimeSpan.Zero)
 152                    throw new InvalidOperationException($"{optionPath}.{nameof(RedisSubscriberOptions.BackgroundDrainTim
 153
 154                // Redis subscribers spend only the background drain at shutdown; the read loop
 155                // stops with the host token and the multiplexer teardown is not separately bounded.
 156                ShutdownBudgetValidator.Validate(
 157                    "Redis",
 158                    $"{nameof(RedisAsyncResponseTransportOptions)}.{nameof(RedisAsyncResponseTransportOptions.HostShutdo
 159                    transportOptions.HostShutdownTimeout,
 160                    ($"{optionPath}.{nameof(RedisSubscriberOptions.BackgroundDrainTimeout)}", subscriberOptions.Backgrou
 161
 162                return;
 163
 164            default:
 165                throw new InvalidOperationException(
 166                    $"{optionPath}.{nameof(RedisSubscriberOptions.AckMode)} has unsupported value '{subscriberOptions.Ac
 167        }
 168    }
 169
 170    /// <summary>Handles the delivered message.</summary>
 171    public abstract Task<RedisDispatchOutcome> HandleAsync(
 172        RedisStreamDelivery delivery,
 173        CancellationToken subscriberCancellationToken);
 174
 175    /// <summary>
 176    /// Whether the dispatcher can accept more deliveries right now. Awaiting dispatchers always can
 177    /// (handlers run inline); the queued dispatcher returns <c>false</c> while its bounded queue is
 178    /// saturated so the subscriber stops pulling new entries into the pending-entry list instead of
 179    /// busy-reading and rejecting them.
 180    /// </summary>
 181    public virtual bool CanAcceptMore => true;
 182
 183    /// <summary>Releases resources held by this instance.</summary>
 184    public virtual ValueTask DisposeAsync() => ValueTask.CompletedTask;
 185
 186    /// <summary>Runs the ExecuteHandlerAsync operation.</summary>
 187    protected async Task ExecuteHandlerAsync(
 188        RedisStreamDelivery delivery,
 189        CancellationToken cancellationToken,
 190        bool logFailures = true)
 191    {
 192        using var activity = AsyncResponseDiagnostics.StartActivity(
 193            "asyncresponse.redis.receive",
 194            ActivityKind.Consumer,
 195            delivery.CorrelationId);
 196        activity?.SetTag("asyncresponse.transport", "redis");
 197        activity?.SetTag("asyncresponse.redis.role", _role.ToString());
 198        activity?.SetTag("asyncresponse.redis.ack_mode", _subscriberOptions.AckMode.ToString());
 199        activity?.SetTag("asyncresponse.redis.delivery_attempt", delivery.Attempt);
 200        activity?.SetTag("messaging.system", "redis");
 201        activity?.SetTag("messaging.destination.name", _stream);
 202        activity?.SetTag("messaging.message.id", delivery.MessageId.ToString());
 203
 204        try
 205        {
 206            await _handler(delivery, cancellationToken).ConfigureAwait(false);
 207        }
 208        catch (Exception ex)
 209        {
 210            if (logFailures)
 211            {
 212                Logger.LogError(
 213                    ex,
 214                    "Redis stream message handling failed for {Stream}/{MessageId}.",
 215                    _stream,
 216                    delivery.MessageId.ToString());
 217            }
 218
 219            AsyncResponseDiagnostics.SetError(activity, ex);
 220            throw;
 221        }
 222    }
 223
 224    /// <summary>Acknowledges the delivered message.</summary>
 225    protected Task AckAsync(RedisStreamDelivery delivery, CancellationToken cancellationToken)
 226        => _database.StreamAcknowledgeAsync(
 227            delivery.Stream,
 228            delivery.ConsumerGroup,
 229            delivery.MessageId,
 230            cancellationToken);
 231
 232    /// <summary>Runs the AlreadyExceededDeliveryAttempts operation.</summary>
 233    protected bool AlreadyExceededDeliveryAttempts(RedisStreamDelivery delivery)
 234        => MaxDeliveryAttempts > 0 && delivery.Attempt > MaxDeliveryAttempts;
 235
 236    /// <summary>Runs the ReachedDeliveryAttempts operation.</summary>
 237    protected bool ReachedDeliveryAttempts(RedisStreamDelivery delivery)
 238        => MaxDeliveryAttempts > 0 && delivery.Attempt >= MaxDeliveryAttempts;
 239
 240    /// <summary>Moves the delivered message to dead-letter storage and acknowledges it.</summary>
 241    protected async Task DeadLetterAndAckAsync(
 242        RedisStreamDelivery delivery,
 243        Exception exception,
 244        string reason,
 245        CancellationToken cancellationToken)
 246    {
 247        if (TransportOptions.DeadLetterEnabled)
 248        {
 249            var fields = new[]
 250            {
 251                new NameValueEntry("sourceStream", delivery.Stream.ToString()),
 252                new NameValueEntry("consumerGroup", delivery.ConsumerGroup.ToString()),
 253                new NameValueEntry("subscriberRole", _role.ToString()),
 254                new NameValueEntry("messageId", delivery.MessageId.ToString()),
 255                new NameValueEntry("correlationId", delivery.CorrelationId ?? string.Empty),
 256                new NameValueEntry("attempt", delivery.Attempt),
 257                new NameValueEntry("reason", reason),
 258                new NameValueEntry("exceptionType", exception.GetType().FullName!),
 259                new NameValueEntry("exceptionMessage", exception.Message),
 260                new NameValueEntry("payload", delivery.Payload),
 261                new NameValueEntry("occurredAtUtc", DateTimeOffset.UtcNow.ToString("O"))
 262            };
 263
 264            await _database.StreamAddAsync(
 265                _keys.DeadLetterStream,
 266                fields,
 267                TransportOptions.DeadLetterStreamMaxLength,
 268                TransportOptions.UseApproximateStreamTrimming,
 269                cancellationToken).ConfigureAwait(false);
 270        }
 271
 272        await AckAsync(delivery, cancellationToken).ConfigureAwait(false);
 273    }
 274
 275    /// <summary>
 276    /// Dead-letters (when enabled) and ACKs a stream entry that could not be turned into a delivery —
 277    /// for example a foreign or malformed entry with no payload field, or a tombstone left behind when
 278    /// trimming evicts a still-pending entry. Without this, such an entry throws before
 279    /// <see cref="HandleAsync"/> runs, so it is never ACKed: the pending-claim loop re-claims it every
 280    /// cycle and the subscriber faults and restarts indefinitely while the entry never drains.
 281    /// </summary>
 282    public async Task DiscardUnprocessableAsync(
 283        RedisKey stream,
 284        RedisValue consumerGroup,
 285        StreamEntry entry,
 286        Exception failure,
 287        CancellationToken cancellationToken)
 288    {
 289        Logger.LogError(
 290            failure,
 291            "Redis entry {MessageId} on {Stream} could not be parsed into a delivery; dead-lettering and ACKing it to av
 292            entry.Id.ToString(),
 293            _stream);
 294
 295        var delivery = new RedisStreamDelivery(
 296            stream,
 297            consumerGroup,
 298            entry.Id,
 299            DescribeRawEntry(entry),
 300            RedisCorrelationIdExtractor.TryReadField(entry, TransportOptions.CorrelationIdField),
 301            Attempt: 0,
 302            entry);
 303
 304        await DeadLetterAndAckAsync(delivery, failure, "unparsable_entry", cancellationToken).ConfigureAwait(false);
 305    }
 306
 307    private static string DescribeRawEntry(StreamEntry entry)
 308        => entry.Values is { Length: > 0 }
 309            ? string.Join("; ", entry.Values.Select(value => $"{value.Name}={value.Value}"))
 310            : string.Empty;
 311
 312    /// <summary>Runs the NotifyBackgroundFailureAsync operation.</summary>
 313    protected async ValueTask NotifyBackgroundFailureAsync(
 314        RedisStreamDelivery delivery,
 315        Exception exception)
 316    {
 317        var callback = _subscriberOptions.OnBackgroundFailure;
 318        if (callback is null)
 319            return;
 320
 321        try
 322        {
 323            await callback(new RedisBackgroundFailureContext(
 324                _stream,
 325                _consumerGroup,
 326                _role.ToString(),
 327                delivery.MessageId.ToString(),
 328                delivery.CorrelationId,
 329                exception)).ConfigureAwait(false);
 330        }
 331        catch (Exception callbackException)
 332        {
 333            Logger.LogError(
 334                callbackException,
 335                "Redis background failure callback failed for already-ACKed message {MessageId} on {Stream}.",
 336                delivery.MessageId.ToString(),
 337                _stream);
 338        }
 339    }
 340}
 341
 342internal sealed class AwaitingRedisMessageDispatcher(
 343    Func<RedisStreamDelivery, CancellationToken, Task> handler,
 344    IRedisStreamDatabase database,
 345    RedisAsyncResponseTransportOptions transportOptions,
 346    RedisSubscriberOptions subscriberOptions,
 347    ILogger logger,
 348    RedisKey stream,
 349    RedisValue consumerGroup,
 350    RedisSubscriberRole role)
 351    : RedisMessageDispatcher(handler, database, transportOptions, subscriberOptions, logger, stream, consumerGroup, role
 352{
 353    /// <summary>Handles the delivered message.</summary>
 354    public override async Task<RedisDispatchOutcome> HandleAsync(
 355        RedisStreamDelivery delivery,
 356        CancellationToken subscriberCancellationToken)
 357    {
 358        if (AlreadyExceededDeliveryAttempts(delivery))
 359        {
 360            await DeadLetterAndAckAsync(
 361                delivery,
 362                new InvalidOperationException($"Redis message exceeded {MaxDeliveryAttempts} delivery attempts."),
 363                "max_delivery_attempts_exceeded",
 364                subscriberCancellationToken).ConfigureAwait(false);
 365            return RedisDispatchOutcome.Processed;
 366        }
 367
 368        try
 369        {
 370            await ExecuteHandlerAsync(delivery, subscriberCancellationToken).ConfigureAwait(false);
 371            await AckAsync(delivery, subscriberCancellationToken).ConfigureAwait(false);
 372        }
 373        catch (OperationCanceledException) when (subscriberCancellationToken.IsCancellationRequested)
 374        {
 375            throw;
 376        }
 377        catch (Exception ex) when (ReachedDeliveryAttempts(delivery))
 378        {
 379            Logger.LogWarning(
 380                ex,
 381                "Redis message {MessageId} reached max delivery attempts ({MaxDeliveryAttempts}); writing to dead-letter
 382                delivery.MessageId.ToString(),
 383                MaxDeliveryAttempts);
 384            await DeadLetterAndAckAsync(
 385                delivery,
 386                ex,
 387                "handler_failed_max_attempts",
 388                CancellationToken.None).ConfigureAwait(false);
 389        }
 390        catch
 391        {
 392            // Leave the entry pending. The subscriber's pending-claim loop reclaims it after
 393            // PendingMessageMinIdleTime, giving Redis-backed retry without a hot loop.
 394        }
 395
 396        return RedisDispatchOutcome.Processed;
 397    }
 398}
 399
 400internal sealed class QueuedRedisMessageDispatcher : RedisMessageDispatcher
 401{
 402    private readonly Channel<RedisStreamDelivery> _queue;
 403    private readonly Task[] _workers;
 404    private readonly CancellationTokenSource _drainCancellation = new();
 405    private readonly TimeSpan _drainTimeout;
 406    private readonly int _capacity;
 407    private readonly string _stream;
 408    private int _pendingCount;
 409    private int _runningCount;
 410    private int _disposeStarted;
 411
 412    /// <summary>Runs the QueuedRedisMessageDispatcher operation.</summary>
 413    public QueuedRedisMessageDispatcher(
 414        Func<RedisStreamDelivery, CancellationToken, Task> handler,
 415        IRedisStreamDatabase database,
 416        RedisAsyncResponseTransportOptions transportOptions,
 417        RedisSubscriberOptions subscriberOptions,
 418        ILogger logger,
 419        RedisKey stream,
 420        RedisValue consumerGroup,
 421        RedisSubscriberRole role)
 422        : base(handler, database, transportOptions, subscriberOptions, logger, stream, consumerGroup, role)
 423    {
 424        _stream = stream.ToString();
 425        _drainTimeout = subscriberOptions.BackgroundDrainTimeout;
 426        _capacity = subscriberOptions.BackgroundQueueCapacity;
 427        _queue = Channel.CreateBounded<RedisStreamDelivery>(new BoundedChannelOptions(subscriberOptions.BackgroundQueueC
 428        {
 429            AllowSynchronousContinuations = false,
 430            FullMode = BoundedChannelFullMode.Wait,
 431            SingleReader = subscriberOptions.BackgroundWorkerCount == 1,
 432            SingleWriter = false
 433        });
 434
 435        _workers = Enumerable.Range(0, subscriberOptions.BackgroundWorkerCount)
 436            .Select(workerIndex => Task.Run(() => RunWorkerAsync(workerIndex)))
 437            .ToArray();
 438
 439        Logger.LogInformation(
 440            "Created Redis ACK-after-enqueue dispatcher for {Stream} with {WorkerCount} worker(s), queue capacity {Queue
 441            _stream,
 442            subscriberOptions.BackgroundWorkerCount,
 443            subscriberOptions.BackgroundQueueCapacity,
 444            _drainTimeout);
 445    }
 446
 447    internal int PendingCount => Volatile.Read(ref _pendingCount);
 448    internal int RunningCount => Volatile.Read(ref _runningCount);
 449
 450    public override bool CanAcceptMore => Volatile.Read(ref _pendingCount) < _capacity;
 451
 452    /// <summary>Handles the delivered message.</summary>
 453    public override async Task<RedisDispatchOutcome> HandleAsync(
 454        RedisStreamDelivery delivery,
 455        CancellationToken subscriberCancellationToken)
 456    {
 457        Interlocked.Increment(ref _pendingCount);
 458        if (!_queue.Writer.TryWrite(delivery))
 459        {
 460            Interlocked.Decrement(ref _pendingCount);
 461            Logger.LogWarning(
 462                "Redis background queue rejected message {MessageId} for {Stream}; leaving it pending for retry. Pending
 463                delivery.MessageId.ToString(),
 464                _stream,
 465                PendingCount,
 466                RunningCount);
 467            return RedisDispatchOutcome.Deferred;
 468        }
 469
 470        // The entry now belongs to a background worker, which decrements _pendingCount when it dequeues.
 471        // Do not touch the counter again here, even if the ACK below fails — otherwise it double-counts.
 472        try
 473        {
 474            await AckAsync(delivery, subscriberCancellationToken).ConfigureAwait(false);
 475        }
 476        catch (Exception ex)
 477        {
 478            Logger.LogError(
 479                ex,
 480                "Failed to ACK Redis message {MessageId} for {Stream} after enqueue; it is being processed but Redis wil
 481                delivery.MessageId.ToString(),
 482                _stream);
 483        }
 484
 485        return RedisDispatchOutcome.Processed;
 486    }
 487
 488    /// <summary>Releases resources held by this instance.</summary>
 489    public override async ValueTask DisposeAsync()
 490    {
 491        if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
 492            return;
 493
 494        Logger.LogInformation(
 495            "Draining Redis ACK-after-enqueue dispatcher for {Stream}. Pending={PendingCount}, Running={RunningCount}.",
 496            _stream,
 497            PendingCount,
 498            RunningCount);
 499        _queue.Writer.TryComplete();
 500
 501        try
 502        {
 503            await Task.WhenAll(_workers).WaitAsync(_drainTimeout).ConfigureAwait(false);
 504            _drainCancellation.Dispose();
 505        }
 506        catch (TimeoutException ex)
 507        {
 508            _drainCancellation.Cancel();
 509            Logger.LogWarning(
 510                ex,
 511                "Timed out while draining Redis ACK-after-enqueue dispatcher for {Stream}. Pending={PendingCount}, Runni
 512                _stream,
 513                PendingCount,
 514                RunningCount);
 515
 516            _ = Task.WhenAll(_workers).ContinueWith(
 517                _ => _drainCancellation.Dispose(),
 518                CancellationToken.None,
 519                TaskContinuationOptions.ExecuteSynchronously,
 520                TaskScheduler.Default);
 521        }
 522    }
 523
 524    private async Task RunWorkerAsync(int workerIndex)
 525    {
 526        await foreach (var delivery in _queue.Reader.ReadAllAsync().ConfigureAwait(false))
 527        {
 528            Interlocked.Decrement(ref _pendingCount);
 529            Interlocked.Increment(ref _runningCount);
 530
 531            try
 532            {
 533                await ExecuteHandlerAsync(
 534                    delivery,
 535                    _drainCancellation.Token,
 536                    logFailures: false).ConfigureAwait(false);
 537            }
 538            catch (OperationCanceledException ex) when (_drainCancellation.IsCancellationRequested)
 539            {
 540                // The drain budget lapsed with this already-ACKed entry still unprocessed: Redis
 541                // will not redeliver it, so surface the drop through OnBackgroundFailure instead of
 542                // losing it silently.
 543                Logger.LogWarning(
 544                    "Redis background handler for already-ACKed message {MessageId} on {Stream} was canceled during disp
 545                    delivery.MessageId.ToString(),
 546                    _stream);
 547                await NotifyBackgroundFailureAsync(delivery, ex).ConfigureAwait(false);
 548            }
 549            catch (Exception ex)
 550            {
 551                Logger.LogError(
 552                    ex,
 553                    "Redis background handler failed for already-ACKed message {MessageId} on {Stream}.",
 554                    delivery.MessageId.ToString(),
 555                    _stream);
 556                await NotifyBackgroundFailureAsync(delivery, ex).ConfigureAwait(false);
 557
 558                try
 559                {
 560                    await DeadLetterAndAckAsync(
 561                        delivery,
 562                        ex,
 563                        "background_handler_failed_after_ack",
 564                        CancellationToken.None).ConfigureAwait(false);
 565                }
 566                catch (Exception deadLetterException)
 567                {
 568                    Logger.LogError(
 569                        deadLetterException,
 570                        "Failed to dead-letter already-ACKed Redis message {MessageId} on {Stream}.",
 571                        delivery.MessageId.ToString(),
 572                        _stream);
 573                }
 574            }
 575            finally
 576            {
 577                Interlocked.Decrement(ref _runningCount);
 578            }
 579        }
 580    }
 581}