| | | 1 | | using Microsoft.Extensions.Hosting; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using Microsoft.Extensions.Options; |
| | | 4 | | using System.Text; |
| | | 5 | | |
| | | 6 | | namespace AsyncResponse.Transports.Kafka; |
| | | 7 | | |
| | | 8 | | internal 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> |
| | 0 | 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> |
| | 434 | 23 | | protected KafkaSubscriberService( |
| | 434 | 24 | | IOptions<KafkaAsyncResponseTransportOptions> options, |
| | 434 | 25 | | IKafkaConsumerClientFactory consumerFactory, |
| | 434 | 26 | | IKafkaProducerClient producer, |
| | 434 | 27 | | IKafkaAdminClient adminClient, |
| | 434 | 28 | | ILogger logger) |
| | | 29 | | { |
| | 434 | 30 | | Options = options.Value; |
| | 434 | 31 | | KafkaTransportOptionsValidator.ValidateCommon(Options); |
| | 434 | 32 | | _consumerFactory = consumerFactory; |
| | 434 | 33 | | _producer = producer; |
| | 434 | 34 | | _adminClient = adminClient; |
| | 434 | 35 | | Logger = logger; |
| | 434 | 36 | | } |
| | | 37 | | |
| | 9214 | 38 | | protected KafkaAsyncResponseTransportOptions Options { get; } |
| | 896 | 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 | | { |
| | 426 | 58 | | KafkaMessageDispatcher.ValidateOptions(Options, SubscriberOptions, SubscriberRole); |
| | 424 | 59 | | return base.StartAsync(cancellationToken); |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | protected override Task ExecuteAsync(CancellationToken stoppingToken) |
| | 424 | 63 | | => SubscriberSupervisor.RunAsync( |
| | 424 | 64 | | RunSubscriberAsync, |
| | 424 | 65 | | stoppingToken, |
| | 10 | 66 | | failures => AsyncResponseRetry.Backoff( |
| | 10 | 67 | | failures, |
| | 10 | 68 | | Options.SubscriberRetryBaseDelay, |
| | 10 | 69 | | Options.SubscriberRetryMaxDelay), |
| | 434 | 70 | | (ex, retryDelay) => Logger.LogWarning( |
| | 434 | 71 | | ex, |
| | 434 | 72 | | "Kafka subscriber failed for topic {Topic} ({Role}); retrying in {RetryDelay}.", |
| | 434 | 73 | | Topic, |
| | 434 | 74 | | SubscriberRole, |
| | 434 | 75 | | retryDelay)); |
| | | 76 | | |
| | | 77 | | private async Task RunSubscriberAsync(CancellationToken stoppingToken) |
| | | 78 | | { |
| | 434 | 79 | | if (Options.CreateTopics) |
| | 420 | 80 | | await EnsureTopicsAsync(stoppingToken).ConfigureAwait(false); |
| | | 81 | | |
| | 434 | 82 | | var consumer = _consumerFactory.Create(SubscriberRole); |
| | | 83 | | try |
| | | 84 | | { |
| | 434 | 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. |
| | 434 | 92 | | using var session = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); |
| | 434 | 93 | | var dispatcher = KafkaMessageDispatcher.Create( |
| | 434 | 94 | | HandleMessageAsync, |
| | 434 | 95 | | consumer, |
| | 434 | 96 | | _producer, |
| | 434 | 97 | | Options, |
| | 434 | 98 | | SubscriberOptions, |
| | 434 | 99 | | Logger, |
| | 434 | 100 | | Topic, |
| | 434 | 101 | | ConsumerGroup, |
| | 434 | 102 | | SubscriberRole); |
| | | 103 | | |
| | 434 | 104 | | Logger.LogInformation( |
| | 434 | 105 | | "Kafka subscriber started. Topic: {Topic}. Group: {ConsumerGroup}. Role: {Role}. AckMode: {AckMode}.", |
| | 434 | 106 | | Topic, |
| | 434 | 107 | | ConsumerGroup, |
| | 434 | 108 | | SubscriberRole, |
| | 434 | 109 | | SubscriberOptions.AckMode); |
| | | 110 | | |
| | 434 | 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). |
| | 434 | 117 | | await Task.Factory.StartNew( |
| | 430 | 118 | | () => RunPollLoop(consumer, dispatcher, session.Token), |
| | 434 | 119 | | stoppingToken, |
| | 434 | 120 | | TaskCreationOptions.LongRunning, |
| | 434 | 121 | | TaskScheduler.Default).ConfigureAwait(false); |
| | 420 | 122 | | } |
| | 14 | 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. |
| | 10 | 133 | | faulted = true; |
| | 10 | 134 | | await dispatcher.TeardownAfterFaultAsync().ConfigureAwait(false); |
| | 10 | 135 | | session.Cancel(); |
| | 10 | 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. |
| | 434 | 144 | | if (!faulted) |
| | 424 | 145 | | await dispatcher.DisposeAsync().ConfigureAwait(false); |
| | | 146 | | } |
| | 420 | 147 | | } |
| | | 148 | | finally |
| | | 149 | | { |
| | 434 | 150 | | CloseQuietly(consumer); |
| | 434 | 151 | | consumer.Dispose(); |
| | | 152 | | } |
| | 420 | 153 | | } |
| | | 154 | | |
| | | 155 | | private void RunPollLoop( |
| | | 156 | | IKafkaConsumerClient consumer, |
| | | 157 | | KafkaMessageDispatcher dispatcher, |
| | | 158 | | CancellationToken stoppingToken) |
| | | 159 | | { |
| | 430 | 160 | | var paused = false; |
| | 3595 | 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. |
| | 3175 | 166 | | dispatcher.SettleCompleted(); |
| | | 167 | | |
| | | 168 | | KafkaIncomingMessage? message; |
| | 3173 | 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. |
| | 12 | 180 | | consumer.PauseAssignment(); |
| | 12 | 181 | | if (!paused) |
| | | 182 | | { |
| | 10 | 183 | | paused = true; |
| | 10 | 184 | | Logger.LogDebug( |
| | 10 | 185 | | "Kafka subscriber for {Topic} paused its assignment: the in-process queue is full.", |
| | 10 | 186 | | Topic); |
| | | 187 | | } |
| | | 188 | | |
| | 12 | 189 | | message = consumer.Consume(SubscriberOptions.BackpressurePollDelay); |
| | | 190 | | } |
| | | 191 | | else |
| | | 192 | | { |
| | 3161 | 193 | | if (paused) |
| | | 194 | | { |
| | 6 | 195 | | consumer.ResumeAssignment(); |
| | 6 | 196 | | paused = false; |
| | 6 | 197 | | Logger.LogDebug( |
| | 6 | 198 | | "Kafka subscriber for {Topic} resumed its assignment: in-process queue capacity freed.", |
| | 6 | 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. |
| | 3161 | 205 | | message = consumer.Consume(dispatcher.HasDetachedWork |
| | 3161 | 206 | | ? SubscriberOptions.BackpressurePollDelay |
| | 3161 | 207 | | : SubscriberOptions.PollTimeout); |
| | | 208 | | } |
| | | 209 | | |
| | 3167 | 210 | | if (message is null) |
| | | 211 | | continue; |
| | | 212 | | |
| | | 213 | | KafkaDelivery delivery; |
| | | 214 | | try |
| | | 215 | | { |
| | 457 | 216 | | delivery = CreateDelivery(message); |
| | 451 | 217 | | } |
| | 6 | 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. |
| | 6 | 234 | | dispatcher.AcceptUnprocessable(message, ex, stoppingToken); |
| | 6 | 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. |
| | 451 | 241 | | dispatcher.Accept(delivery, stoppingToken); |
| | | 242 | | } |
| | 420 | 243 | | } |
| | | 244 | | |
| | | 245 | | private async Task EnsureTopicsAsync(CancellationToken cancellationToken) |
| | | 246 | | { |
| | 420 | 247 | | var topics = new List<string>(2) { Topic }; |
| | 420 | 248 | | if (Options.DeadLetterEnabled) |
| | 420 | 249 | | topics.Add(new KafkaTransportTopicSchema(Options).DeadLetterTopicFor(Topic)); |
| | | 250 | | |
| | 420 | 251 | | await _adminClient.EnsureTopicsAsync( |
| | 420 | 252 | | topics, |
| | 420 | 253 | | Options.TopicNumPartitions, |
| | 420 | 254 | | Options.TopicReplicationFactor, |
| | 420 | 255 | | cancellationToken).ConfigureAwait(false); |
| | 420 | 256 | | } |
| | | 257 | | |
| | | 258 | | private KafkaDelivery CreateDelivery(KafkaIncomingMessage message) |
| | | 259 | | { |
| | 457 | 260 | | var payload = message.Payload is { Length: > 0 } |
| | 457 | 261 | | ? Encoding.UTF8.GetString(message.Payload) |
| | 457 | 262 | | : null; |
| | 457 | 263 | | if (string.IsNullOrWhiteSpace(payload)) |
| | | 264 | | { |
| | 6 | 265 | | throw new InvalidDataException( |
| | 6 | 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. |
| | 451 | 271 | | var correlationId = SubscriberRole is KafkaSubscriberRole.ResponseIngress |
| | 451 | 272 | | ? IsWithinInboundBudget(payload) |
| | 451 | 273 | | ? KafkaCorrelationIdExtractor.Extract(message.Headers, payload, Options) |
| | 451 | 274 | | : null |
| | 451 | 275 | | : KafkaCorrelationIdExtractor.TryReadHeader(message.Headers, Options.CorrelationIdHeader); |
| | | 276 | | |
| | 451 | 277 | | return new KafkaDelivery( |
| | 451 | 278 | | message.Topic, |
| | 451 | 279 | | message.Partition, |
| | 451 | 280 | | message.Offset, |
| | 451 | 281 | | payload, |
| | 451 | 282 | | correlationId, |
| | 451 | 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. |
| | 434 | 292 | | consumer.Close(); |
| | 432 | 293 | | } |
| | 2 | 294 | | catch (Exception ex) |
| | | 295 | | { |
| | 2 | 296 | | Logger.LogWarning( |
| | 2 | 297 | | ex, |
| | 2 | 298 | | "Kafka consumer for topic {Topic} ({Role}) failed to close cleanly; uncommitted offsets will be redelive |
| | 2 | 299 | | Topic, |
| | 2 | 300 | | SubscriberRole); |
| | 2 | 301 | | } |
| | 434 | 302 | | } |
| | | 303 | | } |
| | | 304 | | |
| | | 305 | | internal 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) |
| | | 318 | | : base(options, consumerFactory, producer, adminClient, logger) |
| | | 319 | | { |
| | | 320 | | _ingress = ingress; |
| | | 321 | | _topics = new KafkaTransportTopicSchema(options.Value); |
| | | 322 | | } |
| | | 323 | | |
| | | 324 | | protected override string Topic => _topics.WorkerTopic; |
| | | 325 | | protected override string ConsumerGroup => Options.WorkerConsumerGroup; |
| | | 326 | | protected override KafkaSubscriberOptions SubscriberOptions => Options.WorkerSubscriber; |
| | | 327 | | protected override KafkaSubscriberRole SubscriberRole => KafkaSubscriberRole.Worker; |
| | | 328 | | |
| | | 329 | | /// <summary>Handles the delivered message.</summary> |
| | | 330 | | protected override Task HandleMessageAsync(KafkaDelivery delivery, CancellationToken cancellationToken) |
| | | 331 | | => _ingress.HandleWorkerMessageAsync(delivery.Payload); |
| | | 332 | | } |
| | | 333 | | |
| | | 334 | | internal 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 | | } |