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

Information
Class: AsyncResponse.Transports.Kafka.KafkaWorkerSubscriber
Assembly: AsyncResponse.Transports.Kafka
File(s): /_/src/Transports/AsyncResponse.Transports.Kafka/KafkaSubscriberServices.cs
Line coverage
100%
Covered lines: 9
Uncovered lines: 0
Coverable lines: 9
Total lines: 364
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)

/_/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    /// <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 readonly IKafkaConsumerClientFactory _consumerFactory;
 19    private readonly IKafkaProducerClient _producer;
 20    private readonly IKafkaAdminClient _adminClient;
 21
 22    /// <summary>Runs the KafkaSubscriberService operation.</summary>
 23    protected KafkaSubscriberService(
 24        IOptions<KafkaAsyncResponseTransportOptions> options,
 25        IKafkaConsumerClientFactory consumerFactory,
 26        IKafkaProducerClient producer,
 27        IKafkaAdminClient adminClient,
 28        ILogger logger)
 29    {
 30        Options = options.Value;
 31        KafkaTransportOptionsValidator.ValidateCommon(Options);
 32        _consumerFactory = consumerFactory;
 33        _producer = producer;
 34        _adminClient = adminClient;
 35        Logger = logger;
 36    }
 37
 38    protected KafkaAsyncResponseTransportOptions Options { get; }
 39    protected ILogger Logger { get; }
 40
 41    protected abstract string Topic { get; }
 42    protected abstract string ConsumerGroup { get; }
 43    protected abstract KafkaSubscriberOptions SubscriberOptions { get; }
 44    protected abstract KafkaSubscriberRole SubscriberRole { get; }
 45    /// <summary>Handles the delivered message.</summary>
 46    protected abstract Task HandleMessageAsync(KafkaDelivery delivery, CancellationToken cancellationToken);
 47
 48    /// <summary>Runs this background operation until cancellation is requested.</summary>
 49    /// <summary>
 50    /// Validates subscriber options here rather than at the top of <c>ExecuteAsync</c>: since
 51    /// Microsoft.Extensions.Hosting.Abstractions 10.0.10, <c>BackgroundService.StartAsync</c> no
 52    /// longer runs <c>ExecuteAsync</c> inline, so a throw there surfaces only through the host's
 53    /// background-exception handling — or never, when a fast stop discards the queued work —
 54    /// instead of failing host startup synchronously.
 55    /// </summary>
 56    public override Task StartAsync(CancellationToken cancellationToken)
 57    {
 58        KafkaMessageDispatcher.ValidateOptions(Options, SubscriberOptions, SubscriberRole);
 59        return base.StartAsync(cancellationToken);
 60    }
 61
 62    protected override Task ExecuteAsync(CancellationToken stoppingToken)
 63        => SubscriberSupervisor.RunAsync(
 64            RunSubscriberAsync,
 65            stoppingToken,
 66            failures => AsyncResponseRetry.Backoff(
 67                failures,
 68                Options.SubscriberRetryBaseDelay,
 69                Options.SubscriberRetryMaxDelay),
 70            (ex, retryDelay) => Logger.LogWarning(
 71                ex,
 72                "Kafka subscriber failed for topic {Topic} ({Role}); retrying in {RetryDelay}.",
 73                Topic,
 74                SubscriberRole,
 75                retryDelay));
 76
 77    private async Task RunSubscriberAsync(CancellationToken stoppingToken)
 78    {
 79        if (Options.CreateTopics)
 80            await EnsureTopicsAsync(stoppingToken).ConfigureAwait(false);
 81
 82        var consumer = _consumerFactory.Create(SubscriberRole);
 83        try
 84        {
 85            consumer.Subscribe(Topic);
 86
 87            // One session token per consumer, linked to the host's: a stop cancels it as before.
 88            // A poll-loop FAULT cancels it too — after the bounded fault teardown below — so a
 89            // detached handler abandoned by that teardown stops retrying a message whose offset
 90            // this session can no longer store, instead of running its whole retry ladder for a
 91            // consumer that is gone.
 92            using var session = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
 93            var dispatcher = KafkaMessageDispatcher.Create(
 94                HandleMessageAsync,
 95                consumer,
 96                _producer,
 97                Options,
 98                SubscriberOptions,
 99                Logger,
 100                Topic,
 101                ConsumerGroup,
 102                SubscriberRole);
 103
 104            Logger.LogInformation(
 105                "Kafka subscriber started. Topic: {Topic}. Group: {ConsumerGroup}. Role: {Role}. AckMode: {AckMode}.",
 106                Topic,
 107                ConsumerGroup,
 108                SubscriberRole,
 109                SubscriberOptions.AckMode);
 110
 111            var faulted = false;
 112            try
 113            {
 114                // Consume() blocks the calling thread, so the poll loop runs on a dedicated thread
 115                // instead of starving the thread pool; the dispatcher's settlements happen on it too
 116                // (the consumer is touched from no other thread while the loop runs).
 117                await Task.Factory.StartNew(
 118                    () => RunPollLoop(consumer, dispatcher, session.Token),
 119                    stoppingToken,
 120                    TaskCreationOptions.LongRunning,
 121                    TaskScheduler.Default).ConfigureAwait(false);
 122            }
 123            catch (Exception) when (!stoppingToken.IsCancellationRequested)
 124            {
 125                // The poll loop failed (a consume error, a dropped connection, a burial that
 126                // failed for good) and the supervisor will rebuild the consumer after its backoff.
 127                // Teardown is BOUNDED here, unlike the graceful stop's: waiting for every detached
 128                // handler with no limit parked the reconnect behind an unrelated long handler — a
 129                // durable-flow step awaiting a remote response — and the configured retry policy
 130                // never ran. Handlers that settle within the budget get their offsets stored
 131                // (the close below commits them); the rest are abandoned with their offsets
 132                // unstored, so their messages redeliver on the rebuilt consumer.
 133                faulted = true;
 134                await dispatcher.TeardownAfterFaultAsync().ConfigureAwait(false);
 135                session.Cancel();
 136                throw;
 137            }
 138            finally
 139            {
 140                // Graceful stop (or a fault racing one): the drain waits for the ACK-after-enqueue
 141                // background queue — or ack-after-handler mode's detached handlers, storing their
 142                // offsets — before the consumer commits its final stored offsets below. The host's
 143                // shutdown budget bounds it.
 144                if (!faulted)
 145                    await dispatcher.DisposeAsync().ConfigureAwait(false);
 146            }
 147        }
 148        finally
 149        {
 150            CloseQuietly(consumer);
 151            consumer.Dispose();
 152        }
 153    }
 154
 155    private void RunPollLoop(
 156        IKafkaConsumerClient consumer,
 157        KafkaMessageDispatcher dispatcher,
 158        CancellationToken stoppingToken)
 159    {
 160        var paused = false;
 161        while (!stoppingToken.IsCancellationRequested)
 162        {
 163            // Handlers that outlived their inline budget are settled here, on the poll thread —
 164            // the only thread that touches the consumer — before the next poll: offset stored,
 165            // partition resumed. A settlement that failed for good throws and faults the loop.
 166            dispatcher.SettleCompleted();
 167
 168            KafkaIncomingMessage? message;
 169            if (!dispatcher.CanAcceptMore)
 170            {
 171                // Backpressure: stop fetching from the assigned partitions while the bounded
 172                // in-process queue is saturated. Keep calling Consume so the broker still sees the
 173                // consumer polling (max.poll.interval.ms) and rebalance callbacks keep firing.
 174                // Re-assert the pause on EVERY saturated tick, not only on the edge: Pause()
 175                // snapshots the CURRENT assignment, and a rebalance during backpressure hands this
 176                // member partitions with their pause state reset — an edge-triggered pause would
 177                // leave those fetching into the full queue and park the poll thread on the bounded
 178                // write. Pause is a local librdkafka call (no broker round trip), so the per-tick
 179                // re-assert is cheap.
 180                consumer.PauseAssignment();
 181                if (!paused)
 182                {
 183                    paused = true;
 184                    Logger.LogDebug(
 185                        "Kafka subscriber for {Topic} paused its assignment: the in-process queue is full.",
 186                        Topic);
 187                }
 188
 189                message = consumer.Consume(SubscriberOptions.BackpressurePollDelay);
 190            }
 191            else
 192            {
 193                if (paused)
 194                {
 195                    consumer.ResumeAssignment();
 196                    paused = false;
 197                    Logger.LogDebug(
 198                        "Kafka subscriber for {Topic} resumed its assignment: in-process queue capacity freed.",
 199                        Topic);
 200                }
 201
 202                // With detached handlers in flight, poll in short slices so a completion is
 203                // settled within BackpressurePollDelay instead of after a full PollTimeout; the
 204                // slice is what bounds the resume latency of the paused partition.
 205                message = consumer.Consume(dispatcher.HasDetachedWork
 206                    ? SubscriberOptions.BackpressurePollDelay
 207                    : SubscriberOptions.PollTimeout);
 208            }
 209
 210            if (message is null)
 211                continue;
 212
 213            KafkaDelivery delivery;
 214            try
 215            {
 216                delivery = CreateDelivery(message);
 217            }
 218            catch (Exception ex) when (ex is not OperationCanceledException)
 219            {
 220                // A foreign/malformed message can never be handled; dead-letter and commit it so
 221                // its partition advances instead of re-failing on every subscriber restart.
 222                //
 223                // Deliberately every non-cancellation exception, not just InvalidDataException:
 224                // CreateDelivery also runs correlation-id extraction, and anything that escapes
 225                // here faults the poll loop with the offset unstored, so the same message re-throws
 226                // after every supervisor restart and the whole subscriber (all assigned partitions)
 227                // stops advancing — MaxDeliveryAttempts cannot help, because it is keyed on a
 228                // delivery this path never constructed.
 229                //
 230                // Through the dispatcher's partition ordering, not a direct discard: storing this
 231                // message's offset while an earlier message of the same partition is still being
 232                // handled (detached) commits the partition PAST that unfinished message, and a
 233                // crash after the commit skips it for good with no dead-letter copy anywhere.
 234                dispatcher.AcceptUnprocessable(message, ex, stoppingToken);
 235                continue;
 236            }
 237
 238            // Settles inline (queued mode, and ack-after-handler mode within DetachHandlerAfter)
 239            // or detaches the handler and returns; either way the poll thread is back here within
 240            // the validated poll gap.
 241            dispatcher.Accept(delivery, stoppingToken);
 242        }
 243    }
 244
 245    private async Task EnsureTopicsAsync(CancellationToken cancellationToken)
 246    {
 247        var topics = new List<string>(2) { Topic };
 248        if (Options.DeadLetterEnabled)
 249            topics.Add(new KafkaTransportTopicSchema(Options).DeadLetterTopicFor(Topic));
 250
 251        await _adminClient.EnsureTopicsAsync(
 252            topics,
 253            Options.TopicNumPartitions,
 254            Options.TopicReplicationFactor,
 255            cancellationToken).ConfigureAwait(false);
 256    }
 257
 258    private KafkaDelivery CreateDelivery(KafkaIncomingMessage message)
 259    {
 260        var payload = message.Payload is { Length: > 0 }
 261            ? Encoding.UTF8.GetString(message.Payload)
 262            : null;
 263        if (string.IsNullOrWhiteSpace(payload))
 264        {
 265            throw new InvalidDataException(
 266                $"Kafka message {message.Topic}[{message.Partition}]@{message.Offset} does not contain a payload.");
 267        }
 268
 269        // Body-path extraction parses the whole payload, so it is gated on the inbound budget;
 270        // the field/header path reads metadata only and is unaffected by payload size.
 271        var correlationId = SubscriberRole is KafkaSubscriberRole.ResponseIngress
 272            ? IsWithinInboundBudget(payload)
 273                ? KafkaCorrelationIdExtractor.Extract(message.Headers, payload, Options)
 274                : null
 275            : KafkaCorrelationIdExtractor.TryReadHeader(message.Headers, Options.CorrelationIdHeader);
 276
 277        return new KafkaDelivery(
 278            message.Topic,
 279            message.Partition,
 280            message.Offset,
 281            payload,
 282            correlationId,
 283            message.Headers);
 284    }
 285
 286    private void CloseQuietly(IKafkaConsumerClient consumer)
 287    {
 288        try
 289        {
 290            // Commits stored offsets and leaves the group cleanly so partitions rebalance
 291            // immediately instead of waiting for the session timeout.
 292            consumer.Close();
 293        }
 294        catch (Exception ex)
 295        {
 296            Logger.LogWarning(
 297                ex,
 298                "Kafka consumer for topic {Topic} ({Role}) failed to close cleanly; uncommitted offsets will be redelive
 299                Topic,
 300                SubscriberRole);
 301        }
 302    }
 303}
 304
 305internal sealed class KafkaWorkerSubscriber : KafkaSubscriberService
 306{
 307    private readonly IAsyncResponseIngress _ingress;
 308    private readonly KafkaTransportTopicSchema _topics;
 309
 310    /// <summary>Runs the KafkaWorkerSubscriber operation.</summary>
 311    public KafkaWorkerSubscriber(
 312        IOptions<KafkaAsyncResponseTransportOptions> options,
 313        IKafkaConsumerClientFactory consumerFactory,
 314        IKafkaProducerClient producer,
 315        IKafkaAdminClient adminClient,
 316        IAsyncResponseIngress ingress,
 317        ILogger<KafkaWorkerSubscriber> logger)
 234318        : base(options, consumerFactory, producer, adminClient, logger)
 319    {
 234320        _ingress = ingress;
 234321        _topics = new KafkaTransportTopicSchema(options.Value);
 234322    }
 323
 1190324    protected override string Topic => _topics.WorkerTopic;
 476325    protected override string ConsumerGroup => Options.WorkerConsumerGroup;
 2921326    protected override KafkaSubscriberOptions SubscriberOptions => Options.WorkerSubscriber;
 1403327    protected override KafkaSubscriberRole SubscriberRole => KafkaSubscriberRole.Worker;
 328
 329    /// <summary>Handles the delivered message.</summary>
 330    protected override Task HandleMessageAsync(KafkaDelivery delivery, CancellationToken cancellationToken)
 450331        => _ingress.HandleWorkerMessageAsync(delivery.Payload);
 332}
 333
 334internal sealed class KafkaResponseIngressSubscriber : KafkaSubscriberService
 335{
 336    private readonly IAsyncResponseIngress _ingress;
 337    private readonly KafkaTransportTopicSchema _topics;
 338
 339    /// <summary>Runs the KafkaResponseIngressSubscriber operation.</summary>
 340    public KafkaResponseIngressSubscriber(
 341        IOptions<KafkaAsyncResponseTransportOptions> options,
 342        IKafkaConsumerClientFactory consumerFactory,
 343        IKafkaProducerClient producer,
 344        IKafkaAdminClient adminClient,
 345        IAsyncResponseIngress ingress,
 346        ILogger<KafkaResponseIngressSubscriber> logger)
 347        : base(options, consumerFactory, producer, adminClient, logger)
 348    {
 349        _ingress = ingress;
 350        _topics = new KafkaTransportTopicSchema(options.Value);
 351    }
 352
 353    protected override string Topic => _topics.ResponseTopic;
 354    protected override string ConsumerGroup => Options.ResponseConsumerGroup;
 355    protected override KafkaSubscriberOptions SubscriberOptions => Options.ResponseSubscriber;
 356    protected override KafkaSubscriberRole SubscriberRole => KafkaSubscriberRole.ResponseIngress;
 357
 358    /// <inheritdoc />
 359    protected override bool IsWithinInboundBudget(string payload) => !_ingress.IsOverInboundBudget(payload);
 360
 361    /// <summary>Handles the delivered message.</summary>
 362    protected override Task HandleMessageAsync(KafkaDelivery delivery, CancellationToken cancellationToken)
 363        => _ingress.HandleResponseMessageAsync(delivery.Payload, delivery.CorrelationId);
 364}