Repository navigation
Obvs.Kafka: carry on after transient errors, and name consumer groups after the service - #140
Merged
Merged
Conversation
… after the service Errors the Kafka client recovers from by itself, such as brokers that are down, closed connections, a failed OAUTHBEARER token refresh, or a message it can't deserialize, are now logged in the Obvs.Kafka category and consumption carries on. Only fatal errors and authorization failures end the subscription. With UseKafka, commands and requests are read in one consumer group per service (<service>.Commands, <service>.Requests), shared by its buses, so each is handled once. Events and responses are read in a group per bus, <service>.Events.<instance ID>, so a bus with a fixed instance ID reuses its groups after a restart. A new group's start position is committed at once, so messages sent while no bus was running aren't skipped. v6's fluent configuration keeps its process-named groups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
22 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the two open
Obvs.Kafkaitems under "Kafka on managed platforms" in #130, both found while building #135.1. Transient errors no longer end subscriptions
Before this change, every error the Kafka client reported went to
observer.OnError, transient ones too, such as "1/1 brokers are down" or a connection the platform closed. An unreadable message also ended the subscription.How the client reports errors, from its own documentation:
SetErrorHandler): Confluent.Kafka's docs say "the client will try to automatically recover from errors that are not marked as fatal", and that non-fatal errors are informational. librdkafka's INTRODUCTION.md says the same abouterror_cb. For a consumer, fatal errors come from static group membership being fenced, and the consumer stops itself afterwards.ConsumeExceptionfromConsume: a consumer error. Most are reported once and the client carries on, for example an unknown topic, or a message that fails deserialization (which has already been consumed). librdkafka says topic authorization failures are reported "once per partition" and the fetcher backs off. The application should act on them, because they don't fix themselves.Local_Authentication. librdkafka keeps the token it has and retries in 10 seconds (rd_kafka_oauthbearer_set_token_failure0inrdkafka_sasl_oauthbearer.c). So authentication errors are treated as transient: ending the subscription on one hiccup at the token endpoint would be wrong. Bad credentials are already caught at startup, whereKafkaTopics.EnsureCreatedfails straight away.So
MessageSourcenow ends the subscription, with aKafkaExceptioncarrying the client'sError, only for:Error.IsFatal;TopicAuthorizationFailed,GroupAuthorizationFailedandClusterAuthorizationFailed.When one of these ends the subscription, the consumer also stops waiting in
Consume. Everything else is logged as a warning in theObvs.Kafkacategory, from the host'sILoggerFactorywhen usingUseKafka, and consumption carries on.2. Consumer groups are named after the service, not the process
How it worked before: v6's fluent configuration put every source in one group,
<machine>-<user>-<pid>.UseKafkaused<machine>-<user>-<pid>-<topic>-<instance ID>for every topic. So withUseKafka, commands and requests were not shared between a service's buses: every bus handled every command. The existing testShouldDeliverToEveryBusInTheProcessasserted that. And every restart created new groups.Now, with
UseKafka:<service>.Commands<service>.Commands, shared by the service's buses, so each command is handled once<service>.Requests<service>.Requests, shared<service>.Events<service>.Events.<instance ID>, one per bus, so every bus gets every event<service>.Responses<service>.Responses.<instance ID>, one per busEach group name starts with
<service>., so access control can grant a prefix instead of*. The Confluent Cloud and MSK pages now say so.Sharing a group needs one more change. A new group's start position (the end of the log) is now committed when partitions are assigned. Without that, a bus that takes over a partition from one that consumed nothing would skip to the end again, missing what was sent in between. A restarted bus would do the same. If that commit fails, the error is logged and the consumer still starts from that position.
v6's
WithKafkaEndpoints<...>()keeps its process-named groups.KafkaServiceEndpointProvider'suniqueGroupSuffixparameter becomesinstanceId, andMessageSource's becomesgroupId. Both were added in v7 (#119) and aren't in a release.Behaviour change:
ShouldDeliverToEveryBusInTheProcessasserted the old behaviour, so I replaced it withShouldHandleEachCommandOnceAcrossTheServicesBusesandShouldDeliverEveryEventToEveryBus. This is the change the task asked for, so the old test wasn't wrong when it was written.Docs
docs/transports/kafka.mdhas new sections: "Consumer groups" (the naming, partitions limiting how far work is shared,UseInstanceId(...)for stable groups, and groups left behind by buses with random instance IDs, which Kafka removesoffsets.retention.minutesafter they empty, 7 days by default) and "Errors while running". The Permissions section now gives the group prefix.kafka-azure-event-hubs.md: the limit of 1,000 Kafka consumer groups per namespace, how many groups each service and bus uses, and the advice to use a fixed instance ID per bus. Microsoft doesn't document when Event Hubs removes unused Kafka groups, and the page says so.kafka-confluent-cloud.md,kafka-amazon-msk.md: prefixed group permissions.Testing
Test-driven: each test below was written first and seen failing for the expected reason before the implementation.
TestMessageSourceErrors, unit tests:Assert.Falsebecause every error ended the subscription.KafkaException. They failed because a plainExceptionwas passed.TestMessageSourceErrors.ShouldSkipAMessageItCantReadAndCarryOn(Explicit) failed because aConsumeException: Local: Value deserialization errorended the subscription.TestKafkaConsumerGroups, unit tests: the share and per-bus tests failed because groups were namedChristophers-MacBook-Pro-christopher-<pid>-orders.…. The v6 test describes behaviour that doesn't change, and passed before and after.TestKafkaHosting(Explicit):ShouldHandleEachCommandOnceAcrossTheServicesBusesfailed with duplicates.ShouldHandleCommandsSentWhileTheServiceWasDownandShouldReceiveEventsPublishedWhileABusWithAFixedInstanceIdWasDownfailed with timeouts: the messages were missed.ShouldDeliverEveryEventToEveryBuspassed before and after.What was run, locally:
Obvs.Kafka.Testssuite against Homebrew Kafka on localhost:9092 (withtest-topic-3) and the SASL broker fromSaslBroker/docker-compose.yml: 106 passed, 0 failed. On their own, the Explicit tests: 19 passed.dotnet build Obvs.Kafkafor net472, netstandard2.0 and net10.0: no errors or warnings.Not tested:
Errorvalues built in the test. I didn't reproduce them against a broker, which would need static membership fencing or ACLs.ILoggerFactoryintoUseKafkahas no test of its own.Test fixture:
TestKafkaHosting.ServiceNamesnow also deletes the run's consumer groups. It skips groups that still have members, because some tests dispose hosts without stopping them.Follow-ups (not in this PR)
PayloadType, a payload the message deserializer can't read, and a throwing property filter. It's inConsumeMessage's generalcatch, not an error from the Kafka client, so I left it alone. It's probably worth the same treatment: skip and log.MessageSource.Dispose()is empty, so a host that's disposed withoutStopAsynckeeps its consumers until the process exits. The fixture cleanup above runs into this.Obvschange was needed.Sources: Confluent.Kafka 2.16 XML docs (
SetErrorHandler,IConsumer.Consume), librdkafka INTRODUCTION.md (fatal errors, unknown or unauthorized topics), librdkafkardkafka_sasl_oauthbearer.c, KIP-211, Event Hubs quotas.🤖 Generated with Claude Code