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

Information
Class: AsyncResponse.Transports.Kafka.KafkaWorkerSubscriber
Assembly: AsyncResponse.Transports.Kafka
File(s): /home/runner/work/AsyncResponse/AsyncResponse/src/Transports/AsyncResponse.Transports.Kafka/KafkaSubscriberServices.cs
Line coverage
100%
Covered lines: 9
Uncovered lines: 0
Coverable lines: 9
Total lines: 291
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%
get_Topic()100%11100%
get_ConsumerGroup()100%11100%
get_SubscriberOptions()100%11100%
get_SubscriberRole()100%11100%
HandleMessageAsync(...)100%11100%

File(s)

/home/runner/work/AsyncResponse/AsyncResponse/src/Transports/AsyncResponse.Transports.Kafka/KafkaSubscriberServices.cs

#LineLine coverage
 1using Microsoft.Extensions.Hosting;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using System.Text;
 5
 6namespace AsyncResponse.Transports.Kafka;
 7
 8internal abstract class KafkaSubscriberService : BackgroundService
 9{
 10    private readonly IKafkaConsumerClientFactory _consumerFactory;
 11    private readonly IKafkaProducerClient _producer;
 12    private readonly IKafkaAdminClient _adminClient;
 13
 14    /// <summary>Runs the KafkaSubscriberService operation.</summary>
 15    protected KafkaSubscriberService(
 16        IOptions<KafkaAsyncResponseTransportOptions> options,
 17        IKafkaConsumerClientFactory consumerFactory,
 18        IKafkaProducerClient producer,
 19        IKafkaAdminClient adminClient,
 20        ILogger logger)
 21    {
 22        Options = options.Value;
 23        KafkaTransportOptionsValidator.ValidateCommon(Options);
 24        _consumerFactory = consumerFactory;
 25        _producer = producer;
 26        _adminClient = adminClient;
 27        Logger = logger;
 28    }
 29
 30    protected KafkaAsyncResponseTransportOptions Options { get; }
 31    protected ILogger Logger { get; }
 32
 33    protected abstract string Topic { get; }
 34    protected abstract string ConsumerGroup { get; }
 35    protected abstract KafkaSubscriberOptions SubscriberOptions { get; }
 36    protected abstract KafkaSubscriberRole SubscriberRole { get; }
 37    /// <summary>Handles the delivered message.</summary>
 38    protected abstract Task HandleMessageAsync(KafkaDelivery delivery, CancellationToken cancellationToken);
 39
 40    /// <summary>Runs this background operation until cancellation is requested.</summary>
 41    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 42    {
 43        KafkaMessageDispatcher.ValidateOptions(Options, SubscriberOptions, SubscriberRole);
 44
 45        var failures = 0;
 46        while (!stoppingToken.IsCancellationRequested)
 47        {
 48            try
 49            {
 50                await RunSubscriberAsync(stoppingToken).ConfigureAwait(false);
 51                return;
 52            }
 53            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 54            {
 55                return;
 56            }
 57            catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
 58            {
 59                failures++;
 60                var retryDelay = AsyncResponseRetry.Backoff(
 61                    failures,
 62                    Options.SubscriberRetryBaseDelay,
 63                    Options.SubscriberRetryMaxDelay);
 64                Logger.LogWarning(
 65                    ex,
 66                    "Kafka subscriber failed for topic {Topic} ({Role}); retrying in {RetryDelay}.",
 67                    Topic,
 68                    SubscriberRole,
 69                    retryDelay);
 70                await Task.Delay(retryDelay, stoppingToken).ConfigureAwait(false);
 71            }
 72        }
 73    }
 74
 75    private async Task RunSubscriberAsync(CancellationToken stoppingToken)
 76    {
 77        if (Options.CreateTopics)
 78            await EnsureTopicsAsync(stoppingToken).ConfigureAwait(false);
 79
 80        var consumer = _consumerFactory.Create(SubscriberRole);
 81        try
 82        {
 83            consumer.Subscribe(Topic);
 84            await using var dispatcher = KafkaMessageDispatcher.Create(
 85                HandleMessageAsync,
 86                consumer,
 87                _producer,
 88                Options,
 89                SubscriberOptions,
 90                Logger,
 91                Topic,
 92                ConsumerGroup,
 93                SubscriberRole);
 94
 95            Logger.LogInformation(
 96                "Kafka subscriber started. Topic: {Topic}. Group: {ConsumerGroup}. Role: {Role}. AckMode: {AckMode}.",
 97                Topic,
 98                ConsumerGroup,
 99                SubscriberRole,
 100                SubscriberOptions.AckMode);
 101
 102            // Consume() blocks the calling thread, so the poll loop runs on a dedicated thread
 103            // instead of starving the thread pool; the dispatcher's async work is awaited from it.
 104            await Task.Factory.StartNew(
 105                () => RunPollLoop(consumer, dispatcher, stoppingToken),
 106                stoppingToken,
 107                TaskCreationOptions.LongRunning,
 108                TaskScheduler.Default).ConfigureAwait(false);
 109
 110            // Leaving the await-using scope drains the ACK-after-enqueue background queue before
 111            // the consumer commits its final stored offsets below.
 112        }
 113        finally
 114        {
 115            CloseQuietly(consumer);
 116            consumer.Dispose();
 117        }
 118    }
 119
 120    private void RunPollLoop(
 121        IKafkaConsumerClient consumer,
 122        KafkaMessageDispatcher dispatcher,
 123        CancellationToken stoppingToken)
 124    {
 125        var paused = false;
 126        while (!stoppingToken.IsCancellationRequested)
 127        {
 128            KafkaIncomingMessage? message;
 129            if (!dispatcher.CanAcceptMore)
 130            {
 131                // Backpressure: stop fetching from the assigned partitions while the bounded
 132                // in-process queue is saturated. Keep calling Consume so the broker still sees the
 133                // consumer polling (max.poll.interval.ms) and rebalance callbacks keep firing.
 134                if (!paused)
 135                {
 136                    consumer.PauseAssignment();
 137                    paused = true;
 138                    Logger.LogDebug(
 139                        "Kafka subscriber for {Topic} paused its assignment: the in-process queue is full.",
 140                        Topic);
 141                }
 142
 143                message = consumer.Consume(SubscriberOptions.BackpressurePollDelay);
 144            }
 145            else
 146            {
 147                if (paused)
 148                {
 149                    consumer.ResumeAssignment();
 150                    paused = false;
 151                    Logger.LogDebug(
 152                        "Kafka subscriber for {Topic} resumed its assignment: in-process queue capacity freed.",
 153                        Topic);
 154                }
 155
 156                message = consumer.Consume(SubscriberOptions.PollTimeout);
 157            }
 158
 159            if (message is null)
 160                continue;
 161
 162            KafkaDelivery delivery;
 163            try
 164            {
 165                delivery = CreateDelivery(message);
 166            }
 167            catch (InvalidDataException ex)
 168            {
 169                // A foreign/malformed message can never be handled; dead-letter and commit it so
 170                // its partition advances instead of re-failing on every subscriber restart.
 171                dispatcher.DiscardUnprocessableAsync(message, ex, stoppingToken).GetAwaiter().GetResult();
 172                continue;
 173            }
 174
 175            dispatcher.HandleAsync(delivery, stoppingToken).GetAwaiter().GetResult();
 176        }
 177    }
 178
 179    private async Task EnsureTopicsAsync(CancellationToken cancellationToken)
 180    {
 181        var topics = new List<string>(2) { Topic };
 182        if (Options.DeadLetterEnabled)
 183            topics.Add(new KafkaTransportTopicSchema(Options).DeadLetterTopicFor(Topic));
 184
 185        await _adminClient.EnsureTopicsAsync(
 186            topics,
 187            Options.TopicNumPartitions,
 188            Options.TopicReplicationFactor,
 189            cancellationToken).ConfigureAwait(false);
 190    }
 191
 192    private KafkaDelivery CreateDelivery(KafkaIncomingMessage message)
 193    {
 194        var payload = message.Payload is { Length: > 0 }
 195            ? Encoding.UTF8.GetString(message.Payload)
 196            : null;
 197        if (string.IsNullOrWhiteSpace(payload))
 198        {
 199            throw new InvalidDataException(
 200                $"Kafka message {message.Topic}[{message.Partition}]@{message.Offset} does not contain a payload.");
 201        }
 202
 203        var correlationId = SubscriberRole is KafkaSubscriberRole.ResponseIngress
 204            ? KafkaCorrelationIdExtractor.Extract(message.Headers, payload, Options)
 205            : KafkaCorrelationIdExtractor.TryReadHeader(message.Headers, Options.CorrelationIdHeader);
 206
 207        return new KafkaDelivery(
 208            message.Topic,
 209            message.Partition,
 210            message.Offset,
 211            payload,
 212            correlationId,
 213            message.Headers);
 214    }
 215
 216    private void CloseQuietly(IKafkaConsumerClient consumer)
 217    {
 218        try
 219        {
 220            // Commits stored offsets and leaves the group cleanly so partitions rebalance
 221            // immediately instead of waiting for the session timeout.
 222            consumer.Close();
 223        }
 224        catch (Exception ex)
 225        {
 226            Logger.LogWarning(
 227                ex,
 228                "Kafka consumer for topic {Topic} ({Role}) failed to close cleanly; uncommitted offsets will be redelive
 229                Topic,
 230                SubscriberRole);
 231        }
 232    }
 233}
 234
 235internal sealed class KafkaWorkerSubscriber : KafkaSubscriberService
 236{
 237    private readonly IAsyncResponseIngress _ingress;
 238    private readonly KafkaTransportTopicSchema _topics;
 239
 240    /// <summary>Runs the KafkaWorkerSubscriber operation.</summary>
 241    public KafkaWorkerSubscriber(
 242        IOptions<KafkaAsyncResponseTransportOptions> options,
 243        IKafkaConsumerClientFactory consumerFactory,
 244        IKafkaProducerClient producer,
 245        IKafkaAdminClient adminClient,
 246        IAsyncResponseIngress ingress,
 247        ILogger<KafkaWorkerSubscriber> logger)
 3248        : base(options, consumerFactory, producer, adminClient, logger)
 249    {
 3250        _ingress = ingress;
 3251        _topics = new KafkaTransportTopicSchema(options.Value);
 3252    }
 253
 3254    protected override string Topic => _topics.WorkerTopic;
 3255    protected override string ConsumerGroup => Options.WorkerConsumerGroup;
 3256    protected override KafkaSubscriberOptions SubscriberOptions => Options.WorkerSubscriber;
 3257    protected override KafkaSubscriberRole SubscriberRole => KafkaSubscriberRole.Worker;
 258
 259    /// <summary>Handles the delivered message.</summary>
 260    protected override Task HandleMessageAsync(KafkaDelivery delivery, CancellationToken cancellationToken)
 3261        => _ingress.HandleWorkerMessageAsync(delivery.Payload);
 262}
 263
 264internal sealed class KafkaResponseIngressSubscriber : KafkaSubscriberService
 265{
 266    private readonly IAsyncResponseIngress _ingress;
 267    private readonly KafkaTransportTopicSchema _topics;
 268
 269    /// <summary>Runs the KafkaResponseIngressSubscriber operation.</summary>
 270    public KafkaResponseIngressSubscriber(
 271        IOptions<KafkaAsyncResponseTransportOptions> options,
 272        IKafkaConsumerClientFactory consumerFactory,
 273        IKafkaProducerClient producer,
 274        IKafkaAdminClient adminClient,
 275        IAsyncResponseIngress ingress,
 276        ILogger<KafkaResponseIngressSubscriber> logger)
 277        : base(options, consumerFactory, producer, adminClient, logger)
 278    {
 279        _ingress = ingress;
 280        _topics = new KafkaTransportTopicSchema(options.Value);
 281    }
 282
 283    protected override string Topic => _topics.ResponseTopic;
 284    protected override string ConsumerGroup => Options.ResponseConsumerGroup;
 285    protected override KafkaSubscriberOptions SubscriberOptions => Options.ResponseSubscriber;
 286    protected override KafkaSubscriberRole SubscriberRole => KafkaSubscriberRole.ResponseIngress;
 287
 288    /// <summary>Handles the delivered message.</summary>
 289    protected override Task HandleMessageAsync(KafkaDelivery delivery, CancellationToken cancellationToken)
 290        => _ingress.HandleResponseMessageAsync(delivery.Payload, delivery.CorrelationId);
 291}