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

Information
Class: AsyncResponse.Transports.Redis.RedisResponseIngressSubscriber
Assembly: AsyncResponse.Transports.Redis
File(s): /_/src/Transports/AsyncResponse.Transports.Redis/RedisSubscriberServices.cs
Line coverage
100%
Covered lines: 14
Uncovered lines: 0
Coverable lines: 14
Total lines: 549
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%
.ctor(...)100%11100%
get_Stream()100%11100%
get_ConsumerGroup()100%11100%
get_SubscriberOptions()100%11100%
get_SubscriberRole()100%11100%
IsWithinInboundBudget(...)100%11100%
HandleMessageAsync(...)100%11100%

File(s)

/_/src/Transports/AsyncResponse.Transports.Redis/RedisSubscriberServices.cs

#LineLine coverage
 1using Microsoft.Extensions.Hosting;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using StackExchange.Redis;
 5
 6namespace AsyncResponse.Transports.Redis;
 7
 8internal abstract class RedisSubscriberService : BackgroundService
 9{
 10    /// <summary>
 11    /// Whether the payload is within the engine's inbound size budget. Overridden by the response
 12    /// ingress subscriber, which is the only role that parses the BODY to find a correlation id —
 13    /// the worker role reads a header and never touches payload size. Default true so a role
 14    /// without a budget behaves exactly as before.
 15    /// </summary>
 16    protected virtual bool IsWithinInboundBudget(string payload) => true;
 17
 18    private static readonly string GeneratedConsumerName = CreateGeneratedConsumerName();
 19
 20    private readonly IRedisStreamDatabase _database;
 21
 22    /// <summary>Runs the RedisSubscriberService operation.</summary>
 23    protected RedisSubscriberService(
 24        IOptions<RedisAsyncResponseTransportOptions> options,
 25        IConnectionMultiplexer multiplexer,
 26        ILogger logger)
 27        : this(
 28            options,
 29            new RedisStreamDatabaseAdapter(multiplexer.GetDatabase(), options.Value.OperationTimeout),
 30            logger)
 31    {
 32    }
 33
 34    /// <summary>Runs the RedisSubscriberService operation.</summary>
 35    protected RedisSubscriberService(
 36        IOptions<RedisAsyncResponseTransportOptions> options,
 37        IRedisStreamDatabase database,
 38        ILogger logger)
 39    {
 40        Options = options.Value;
 41        RedisTransportOptionsValidator.ValidateCommon(Options);
 42        _database = database;
 43        Logger = logger;
 44    }
 45
 46    protected RedisAsyncResponseTransportOptions Options { get; }
 47    protected ILogger Logger { get; }
 48
 49    protected abstract RedisKey Stream { get; }
 50    protected abstract RedisValue ConsumerGroup { get; }
 51    protected abstract RedisSubscriberOptions SubscriberOptions { get; }
 52    protected abstract RedisSubscriberRole SubscriberRole { get; }
 53    /// <summary>Handles the delivered message.</summary>
 54    protected abstract Task HandleMessageAsync(RedisStreamDelivery delivery, CancellationToken cancellationToken);
 55
 56    /// <summary>Runs this background operation until cancellation is requested.</summary>
 57    /// <summary>
 58    /// Validates subscriber options here rather than at the top of <c>ExecuteAsync</c>: since
 59    /// Microsoft.Extensions.Hosting.Abstractions 10.0.10, <c>BackgroundService.StartAsync</c> no
 60    /// longer runs <c>ExecuteAsync</c> inline, so a throw there surfaces only through the host's
 61    /// background-exception handling — or never, when a fast stop discards the queued work —
 62    /// instead of failing host startup synchronously.
 63    /// </summary>
 64    public override Task StartAsync(CancellationToken cancellationToken)
 65    {
 66        RedisMessageDispatcher.ValidateOptions(Options, SubscriberOptions, SubscriberRole);
 67        return base.StartAsync(cancellationToken);
 68    }
 69
 70    protected override Task ExecuteAsync(CancellationToken stoppingToken)
 71        => SubscriberSupervisor.RunAsync(
 72            RunSubscriberAsync,
 73            stoppingToken,
 74            failures => AsyncResponseRetry.Backoff(
 75                failures,
 76                Options.SubscriberRetryBaseDelay,
 77                Options.SubscriberRetryMaxDelay),
 78            (ex, retryDelay) => Logger.LogWarning(
 79                ex,
 80                "Redis subscriber failed for stream {Stream} ({Role}); retrying in {RetryDelay}.",
 81                Stream.ToString(),
 82                SubscriberRole,
 83                retryDelay));
 84
 85    private async Task RunSubscriberAsync(CancellationToken stoppingToken)
 86    {
 87        if (Options.CreateConsumerGroups)
 88            await EnsureConsumerGroupAsync(stoppingToken).ConfigureAwait(false);
 89
 90        var consumerName = ResolveConsumerName(Options, SubscriberRole);
 91        await using var dispatcher = RedisMessageDispatcher.Create(
 92            HandleMessageAsync,
 93            _database,
 94            Options,
 95            SubscriberOptions,
 96            Logger,
 97            Stream,
 98            ConsumerGroup,
 99            SubscriberRole);
 100
 101        Logger.LogInformation(
 102            "Redis subscriber started. Stream: {Stream}. Group: {ConsumerGroup}. Consumer: {ConsumerName}. Role: {Role}.
 103            Stream.ToString(),
 104            ConsumerGroup.ToString(),
 105            consumerName.ToString(),
 106            SubscriberRole,
 107            SubscriberOptions.AckMode);
 108
 109        var nextPendingClaimAt = DateTimeOffset.UtcNow;
 110        while (!stoppingToken.IsCancellationRequested)
 111        {
 112            var processed = 0;
 113
 114            // When the dispatcher is saturated (ACK-after-enqueue queue full) stop pulling new entries:
 115            // reading them would only move the backlog into the pending-entry list and spin the loop.
 116            // The unread entries stay as new messages in the stream until capacity frees.
 117            if (dispatcher.CanAcceptMore)
 118            {
 119                var utcNow = DateTimeOffset.UtcNow;
 120                if (utcNow >= nextPendingClaimAt)
 121                {
 122                    processed += await ClaimPendingAsync(dispatcher, consumerName, stoppingToken).ConfigureAwait(false);
 123                    nextPendingClaimAt = utcNow + SubscriberOptions.PendingClaimInterval;
 124                }
 125
 126                // Clamp the read to the dispatcher's FREE slots (ASB/SQS parity), not merely to
 127                // "is there one": a full BatchSize read into a nearly-full early-ACK queue deferred
 128                // the surplus into the PEL un-ACKed, every reclaim bumped its delivery count, and
 129                // the pre-execution cap eventually dead-lettered healthy jobs whose handler never
 130                // ran. The claim above may have taken the last slot.
 131                var readCount = Math.Min(SubscriberOptions.BatchSize, dispatcher.FreeCapacity);
 132                if (readCount > 0)
 133                {
 134                    var entries = await _database.StreamReadGroupAsync(
 135                        Stream,
 136                        ConsumerGroup,
 137                        consumerName,
 138                        readCount,
 139                        stoppingToken).ConfigureAwait(false);
 140
 141                    processed += await DispatchBatchAsync(
 142                        dispatcher,
 143                        entries,
 144                        consumerName,
 145                        static _ => 1,
 146                        stoppingToken).ConfigureAwait(false);
 147                }
 148            }
 149
 150            // Throttle when nothing advanced — an empty stream, or every entry deferred under backpressure.
 151            if (processed == 0)
 152                await Task.Delay(SubscriberOptions.EmptyPollDelay, stoppingToken).ConfigureAwait(false);
 153        }
 154    }
 155
 156    private async Task<RedisDispatchOutcome> DispatchEntryAsync(
 157        RedisMessageDispatcher dispatcher,
 158        StreamEntry entry,
 159        int attempt,
 160        CancellationToken cancellationToken)
 161    {
 162        RedisStreamDelivery delivery;
 163        try
 164        {
 165            delivery = CreateDelivery(entry, attempt);
 166        }
 167        catch (Exception ex) when (ex is not OperationCanceledException)
 168        {
 169            // A foreign/malformed entry (or a trimmed tombstone) can never be handled; dead-letter and
 170            // ACK it so it drains instead of poisoning the pending-claim loop forever.
 171            //
 172            // Deliberately every non-cancellation exception, not just InvalidDataException:
 173            // CreateDelivery also runs correlation-id extraction, and anything that escapes here
 174            // leaves the entry in the PEL to be reclaimed and re-thrown every PendingClaimInterval,
 175            // abandoning the rest of the claimed batch behind it — MaxDeliveryAttempts cannot help,
 176            // because it is keyed on a delivery this path never constructed.
 177            await dispatcher.DiscardUnprocessableAsync(Stream, ConsumerGroup, entry, ex, cancellationToken).ConfigureAwa
 178            return RedisDispatchOutcome.Processed;
 179        }
 180
 181        return await dispatcher.HandleAsync(delivery, cancellationToken).ConfigureAwait(false);
 182    }
 183
 184    private async Task EnsureConsumerGroupAsync(CancellationToken cancellationToken)
 185    {
 186        try
 187        {
 188            await _database.StreamCreateConsumerGroupAsync(
 189                Stream,
 190                ConsumerGroup,
 191                StreamPosition.Beginning,
 192                createStream: true,
 193                cancellationToken).ConfigureAwait(false);
 194        }
 195        catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
 196        {
 197            // The group already exists; this is the expected path after the first app instance.
 198        }
 199    }
 200
 201    private async Task<int> ClaimPendingAsync(
 202        RedisMessageDispatcher dispatcher,
 203        RedisValue consumerName,
 204        CancellationToken cancellationToken)
 205    {
 206        // Same clamp as the read: reclaiming more than the dispatcher can take defers the rest
 207        // straight back into the PEL with a bumped delivery count.
 208        var claimCount = Math.Min(SubscriberOptions.PendingClaimBatchSize, dispatcher.FreeCapacity);
 209        if (claimCount <= 0)
 210            return 0;
 211
 212        var minIdleMs = ToPositiveMilliseconds(SubscriberOptions.PendingMessageMinIdleTime);
 213        var pending = await _database.StreamPendingMessagesAsync(
 214            Stream,
 215            ConsumerGroup,
 216            claimCount,
 217            RedisValue.Null,
 218            minId: null,
 219            maxId: null,
 220            minIdleMs,
 221            cancellationToken).ConfigureAwait(false);
 222
 223        if (pending.Length == 0)
 224            return 0;
 225
 226        var pendingById = pending.ToDictionary(
 227            item => item.MessageId.ToString(),
 228            StringComparer.Ordinal);
 229        var claimed = await _database.StreamClaimAsync(
 230            Stream,
 231            ConsumerGroup,
 232            consumerName,
 233            minIdleMs,
 234            pending.Select(item => item.MessageId).ToArray(),
 235            cancellationToken).ConfigureAwait(false);
 236
 237        // Redis 5/6 answer XCLAIM with a nil entry for an id whose message was trimmed while still
 238        // pending (7.x drops it from the PEL instead). A nil entry has no id, so neither the
 239        // dispatch path nor the JUSTID heartbeat can name it, and it stayed in the PEL to be
 240        // re-claimed every cycle. When the reply is complete its order matches the request, so
 241        // the tombstones are ACKed by their pending ids here; a partial reply leaves them for the
 242        // next cycle. Either way only real entries are dispatched.
 243        if (Array.Exists(claimed, static entry => entry.Id.IsNull))
 244        {
 245            if (claimed.Length == pending.Length)
 246            {
 247                for (var index = 0; index < claimed.Length; index++)
 248                {
 249                    if (!claimed[index].Id.IsNull)
 250                        continue;
 251
 252                    var tombstoneId = pending[index].MessageId;
 253                    try
 254                    {
 255                        await _database.StreamAcknowledgeAsync(Stream, ConsumerGroup, tombstoneId, CancellationToken.Non
 256                        Logger.LogWarning(
 257                            "Redis pending entry {MessageId} on {Stream} was trimmed while still pending; ACKed the tomb
 258                            tombstoneId.ToString(),
 259                            Stream.ToString());
 260                    }
 261                    catch (Exception ex)
 262                    {
 263                        Logger.LogWarning(
 264                            ex,
 265                            "Failed to ACK trimmed pending entry {MessageId} on {Stream}; it is retried on the next pend
 266                            tombstoneId.ToString(),
 267                            Stream.ToString());
 268                    }
 269                }
 270            }
 271
 272            claimed = Array.FindAll(claimed, static entry => !entry.Id.IsNull);
 273        }
 274
 275        return await DispatchBatchAsync(
 276            dispatcher,
 277            claimed,
 278            consumerName,
 279            entry =>
 280            {
 281                var priorDeliveries = pendingById.TryGetValue(entry.Id.ToString(), out var info)
 282                    ? info.DeliveryCount
 283                    : 1;
 284                return Math.Max(1, priorDeliveries + 1);
 285            },
 286            cancellationToken).ConfigureAwait(false);
 287    }
 288
 289    private async Task<int> DispatchBatchAsync(
 290        RedisMessageDispatcher dispatcher,
 291        StreamEntry[] entries,
 292        RedisValue consumerName,
 293        Func<StreamEntry, int> attemptFor,
 294        CancellationToken stoppingToken)
 295    {
 296        if (entries.Length == 0)
 297            return 0;
 298
 299        // The batch is dispatched serially, and XREADGROUP/XCLAIM stamped every entry's idle
 300        // clock at read time — so a slow handler lets the idle time of the later (still
 301        // unprocessed) entries cross PendingMessageMinIdleTime, where a sibling's pending-claim
 302        // scan steals and re-runs them concurrently, bumping their PEL delivery count toward the
 303        // dead-letter cap on work that never once failed. While the batch is in flight, a
 304        // heartbeat claims the unprocessed entries back to this consumer with XCLAIM JUSTID,
 305        // which resets idle WITHOUT bumping the delivery count.
 306        var progress = new BatchProgress();
 307        using var renewalCancellation = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
 308        var renewalTask = RenewClaimLoopAsync(entries, consumerName, progress, renewalCancellation.Token);
 309        var processed = 0;
 310        try
 311        {
 312            foreach (var entry in entries)
 313            {
 314                try
 315                {
 316                    if (await DispatchEntryAsync(dispatcher, entry, attemptFor(entry), stoppingToken).ConfigureAwait(fal
 317                        == RedisDispatchOutcome.Processed)
 318                    {
 319                        processed++;
 320                    }
 321                }
 322                finally
 323                {
 324                    // Also counts Deferred entries: they were left pending ON PURPOSE, so the
 325                    // heartbeat must stop touching them and let their idle accrue toward reclaim.
 326                    progress.MarkSettled();
 327                }
 328            }
 329        }
 330        finally
 331        {
 332            renewalCancellation.Cancel();
 333            await renewalTask.ConfigureAwait(false);
 334        }
 335
 336        return processed;
 337    }
 338
 339    private async Task RenewClaimLoopAsync(
 340        StreamEntry[] entries,
 341        RedisValue consumerName,
 342        BatchProgress progress,
 343        CancellationToken cancellationToken)
 344    {
 345        // ~PendingMessageMinIdleTime/3: two chances to land an idle reset inside every reclaim
 346        // window even when one sweep is delayed by a slow round trip.
 347        var interval = TimeSpan.FromMilliseconds(Math.Max(1, SubscriberOptions.PendingMessageMinIdleTime.TotalMillisecon
 348        try
 349        {
 350            while (true)
 351            {
 352                await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
 353
 354                // Claim from the first unsettled entry onward: that covers the entry currently in
 355                // the handler plus everything still waiting its turn. minIdle 0 resets the idle
 356                // clock unconditionally, and the races are harmless — an entry ACKed while this
 357                // sweep is in flight has left the PEL (the claim simply skips it), and a failed
 358                // entry is settled before MarkSettled runs, so its post-failure idle countdown is
 359                // never stretched.
 360                var settled = progress.SettledCount;
 361                if (settled >= entries.Length)
 362                    return;
 363
 364                var remaining = new RedisValue[entries.Length - settled];
 365                for (var i = settled; i < entries.Length; i++)
 366                    remaining[i - settled] = entries[i].Id;
 367
 368                try
 369                {
 370                    await _database.StreamClaimIdsOnlyAsync(
 371                        Stream,
 372                        ConsumerGroup,
 373                        consumerName,
 374                        minIdleTimeInMilliseconds: 0,
 375                        remaining,
 376                        cancellationToken).ConfigureAwait(false);
 377                }
 378                catch (Exception ex) when (ex is not OperationCanceledException)
 379                {
 380                    Logger.LogWarning(
 381                        ex,
 382                        "Failed to refresh the pending idle time of {EntryCount} Redis entries on {Stream}; a sibling ma
 383                        remaining.Length,
 384                        Stream.ToString());
 385                }
 386            }
 387        }
 388        catch (OperationCanceledException)
 389        {
 390            // The batch finished or the subscriber is stopping.
 391        }
 392    }
 393
 394    private sealed class BatchProgress
 395    {
 396        // Settled only ever increments, so a monotonic volatile read is enough — no lock, and a
 397        // stale read only claims an already-settled entry once more (an ACKed id is simply
 398        // absent from the PEL, and a failed one gets its reclaim delayed by one sweep).
 399        private int _settledCount;
 400
 401        public int SettledCount => Volatile.Read(ref _settledCount);
 402
 403        public void MarkSettled() => Interlocked.Increment(ref _settledCount);
 404    }
 405
 406    private RedisStreamDelivery CreateDelivery(StreamEntry entry, int attempt)
 407    {
 408        var payload = RedisCorrelationIdExtractor.TryReadField(entry, Options.PayloadField);
 409        if (string.IsNullOrWhiteSpace(payload))
 410        {
 411            throw new InvalidDataException(
 412                $"Redis stream entry {entry.Id} on {Stream.ToString()} does not contain payload field '{Options.PayloadF
 413        }
 414
 415        // Body-path extraction parses the whole payload, so it is gated on the inbound budget;
 416        // the field/header path reads metadata only and is unaffected by payload size.
 417        var correlationId = SubscriberRole is RedisSubscriberRole.ResponseIngress
 418            ? IsWithinInboundBudget(payload)
 419                ? RedisCorrelationIdExtractor.Extract(entry, payload, Options)
 420                : null
 421            : RedisCorrelationIdExtractor.TryReadField(entry, Options.CorrelationIdField);
 422
 423        return new RedisStreamDelivery(
 424            Stream,
 425            ConsumerGroup,
 426            entry.Id,
 427            payload,
 428            correlationId,
 429            attempt,
 430            entry);
 431    }
 432
 433    private static long ToPositiveMilliseconds(TimeSpan value)
 434        => Math.Max(1, (long)Math.Ceiling(value.TotalMilliseconds));
 435
 436    private static RedisValue ResolveConsumerName(
 437        RedisAsyncResponseTransportOptions options,
 438        RedisSubscriberRole role)
 439    {
 440        // Append the role even to an explicitly configured name so the worker and response subscribers
 441        // never share a consumer identity (and therefore a pending-entry list) within their groups.
 442        var baseName = !string.IsNullOrWhiteSpace(options.ConsumerName)
 443            ? options.ConsumerName
 444            : GeneratedConsumerName;
 445
 446        return $"{baseName}-{role.ToString().ToLowerInvariant()}";
 447    }
 448
 449    private static string CreateGeneratedConsumerName()
 450        => ComposeGeneratedConsumerName(Environment.MachineName, Environment.ProcessId, Guid.NewGuid());
 451
 452    /// <summary>Length budget of the generated consumer name, before the role suffix.</summary>
 453    internal const int MaxGeneratedConsumerNameLength = 64;
 454
 455    /// <summary>
 456    /// <c>{machine}-{pid}-{guid}</c> within <see cref="MaxGeneratedConsumerNameLength"/>, with the
 457    /// MACHINE NAME giving up the characters. The process id and the GUID are the only parts that
 458    /// make the name unique, and they sit at the end: the old head-keeping cut
 459    /// (<c>name[..64]</c>) removed them first, so a 63-character host name — a Kubernetes pod
 460    /// name, or any host at Linux's HOST_NAME_MAX — left every process on that host with the SAME
 461    /// consumer identity. Consumers that share a name share one pending-entry list inside the
 462    /// group: each could claim and acknowledge entries the other was still handling. The suffix is
 463    /// at most 44 characters, so the machine name always keeps at least 20.
 464    /// </summary>
 465    internal static string ComposeGeneratedConsumerName(string machineName, int processId, Guid instance)
 466    {
 467        var suffix = $"-{processId.ToString(System.Globalization.CultureInfo.InvariantCulture)}-{instance:N}";
 468        return PortableText.TruncateWellFormed(machineName, MaxGeneratedConsumerNameLength - suffix.Length) + suffix;
 469    }
 470}
 471
 472internal sealed class RedisWorkerSubscriber : RedisSubscriberService
 473{
 474    private readonly IAsyncResponseIngress _ingress;
 475    private readonly RedisTransportKeySchema _keys;
 476
 477    /// <summary>Runs the RedisWorkerSubscriber operation.</summary>
 478    public RedisWorkerSubscriber(
 479        IOptions<RedisAsyncResponseTransportOptions> options,
 480        IConnectionMultiplexer multiplexer,
 481        IAsyncResponseIngress ingress,
 482        ILogger<RedisWorkerSubscriber> logger)
 483        : base(options, multiplexer, logger)
 484    {
 485        _ingress = ingress;
 486        _keys = new RedisTransportKeySchema(options.Value);
 487    }
 488
 489    internal RedisWorkerSubscriber(
 490        IOptions<RedisAsyncResponseTransportOptions> options,
 491        IRedisStreamDatabase database,
 492        IAsyncResponseIngress ingress,
 493        ILogger<RedisWorkerSubscriber> logger)
 494        : base(options, database, logger)
 495    {
 496        _ingress = ingress;
 497        _keys = new RedisTransportKeySchema(options.Value);
 498    }
 499
 500    protected override RedisKey Stream => _keys.WorkerStream;
 501    protected override RedisValue ConsumerGroup => Options.WorkerConsumerGroup;
 502    protected override RedisSubscriberOptions SubscriberOptions => Options.WorkerSubscriber;
 503    protected override RedisSubscriberRole SubscriberRole => RedisSubscriberRole.Worker;
 504
 505    /// <summary>Handles the delivered message.</summary>
 506    protected override Task HandleMessageAsync(RedisStreamDelivery delivery, CancellationToken cancellationToken)
 507        => _ingress.HandleWorkerMessageAsync(delivery.Payload);
 508}
 509
 510internal sealed class RedisResponseIngressSubscriber : RedisSubscriberService
 511{
 512    private readonly IAsyncResponseIngress _ingress;
 513    private readonly RedisTransportKeySchema _keys;
 514
 515    /// <summary>Runs the RedisResponseIngressSubscriber operation.</summary>
 516    public RedisResponseIngressSubscriber(
 517        IOptions<RedisAsyncResponseTransportOptions> options,
 518        IConnectionMultiplexer multiplexer,
 519        IAsyncResponseIngress ingress,
 520        ILogger<RedisResponseIngressSubscriber> logger)
 195521        : base(options, multiplexer, logger)
 522    {
 195523        _ingress = ingress;
 195524        _keys = new RedisTransportKeySchema(options.Value);
 195525    }
 526
 527    internal RedisResponseIngressSubscriber(
 528        IOptions<RedisAsyncResponseTransportOptions> options,
 529        IRedisStreamDatabase database,
 530        IAsyncResponseIngress ingress,
 531        ILogger<RedisResponseIngressSubscriber> logger)
 2532        : base(options, database, logger)
 533    {
 2534        _ingress = ingress;
 2535        _keys = new RedisTransportKeySchema(options.Value);
 2536    }
 537
 2661538    protected override RedisKey Stream => _keys.ResponseStream;
 2661539    protected override RedisValue ConsumerGroup => Options.ResponseConsumerGroup;
 4929540    protected override RedisSubscriberOptions SubscriberOptions => Options.ResponseSubscriber;
 774541    protected override RedisSubscriberRole SubscriberRole => RedisSubscriberRole.ResponseIngress;
 542
 543    /// <inheritdoc />
 2544    protected override bool IsWithinInboundBudget(string payload) => !_ingress.IsOverInboundBudget(payload);
 545
 546    /// <summary>Handles the delivered message.</summary>
 547    protected override Task HandleMessageAsync(RedisStreamDelivery delivery, CancellationToken cancellationToken)
 2548        => _ingress.HandleResponseMessageAsync(delivery.Payload, delivery.CorrelationId);
 549}