Skip to content

v7: UseKafka with options from configuration, and headers over Kafka - #119

Merged
christopherread merged 1 commit into
v7from
v7-kafka
Oct 25, 2026
Merged

christopherread merged 1 commit into
v7from
v7-kafka

Conversation

@christopherread

Copy link
Copy Markdown
Owner

Next v7 increment: UseKafka() for #84, following UseRabbitMq() (#112) and UseNats() (#118). It also carries message headers, including trace context, over Kafka (#90). Targets v7.

API

builder.Services.AddObvs(obvs => obvs
    .AddService<IOrderMessage>("orders", service => service
        .UseKafka(builder.Configuration.GetSection("Obvs:Orders:Kafka"))   // or .UseKafka(o => o.BootstrapServers = "localhost:9092")
        .SerializedWith(new JsonMessageSerializer(), new JsonMessageDeserializerFactory(typeof(JsonMessageDeserializer<>)))));
  • Options: KafkaOptions has BootstrapServers (required). Each service's settings are named options keyed by the service name: IOptionsMonitor<KafkaOptions>.Get("orders"). v6's fetch and batch tuning isn't exposed yet.
  • Startup checks: missing brokers or a missing serializer fail at startup, inside the same ObvsConfigurationException as any other problem. The message names the configuration key to set (for example Obvs:Orders:Kafka:BootstrapServers) or the method to call.

How Kafka differs from the other transports

  • Topics are created at startup.
    • What: UseKafka creates whichever of the service's four topics don't exist yet: orders.Commands, .Events, .Requests and .Responses. It uses the broker's default partitions and replication, and waits until every partition has a leader.
    • Why: consumers don't create topics, and one that subscribes before its topic exists can take minutes to notice it, missing what's published meanwhile.
    • Existing topics: topics that already exist are left alone, so a cluster that doesn't allow creating topics works as long as they're there.
  • Each source has its own consumer group, named from the client ID, the topic and the bus's instance ID. This mirrors RabbitMQ's per-bus queue suffix (v7: UseRabbitMq, options from configuration, per-bus instance ID #112).

Two Kafka transport bugs this exposed and fixes

Both also affect v6. UseKafka avoids the first one; the second fix applies to v6's fluent setup too.

  1. Buses in one process shared a consumer group.
    • Cause: v6 names the group after the machine, user and process. Kafka gives each message to only one member of a group, so a second bus in the process missed messages.
    • A second effect: sources for different topics shared that group too. Each new subscription therefore paused all the others for a rebalance, and one consumer that stopped polling stalled every rebalance.
    • Symptoms: ShouldDeliverToEveryBusInTheProcess and ShouldReturnResponsesOverKafka timed out.
    • Fix: UseKafka passes a unique group suffix. v6's fluent setup keeps its group naming.
  2. Deadlock when disposing the bus from a subscriber.

Headers over Kafka

  • Sending: the publisher sends ObvsMessageHeaders as Kafka headers, UTF-8 encoded.
  • Receiving: the source copies Kafka headers back onto the received message before subscribers see it. The receiver's process span therefore continues the sender's trace.
  • Compatibility: v6 receivers ignore Kafka headers, so v6 and v7 services can still exchange messages.

Tests (written first)

  • Configuration (no broker): 5 tests in Obvs.Kafka.Tests/Hosting/TestUseKafka.cs. They cover options from configuration and from code, missing brokers (in configuration and in code), and a missing serializer.
  • End to end (Explicit, real broker): 8 tests in TestKafkaHosting.cs. They cover:
    • topic creation;
    • commands and events;
    • request and response;
    • every bus in a process receiving every message;
    • two hosts in one process;
    • disposing the bus from a message callback;
    • trace continuation;
    • headers the app sets.
      Service names are unique to each run, so every run creates its topics, and a fixture deletes them afterwards.
  • Red first:
    • All 13 tests failed with NotImplementedException before implementation.
    • Once UseKafka worked, the remaining failures matched their causes: the every-bus and request-response tests timed out, the dispose test hung, and the header and trace tests failed on a missing x-tenant header and a different trace ID.
    • Each fix turned its own tests green.
  • Mutation check: passing no group suffix makes the every-bus and request-response tests fail again.
  • Results:
    • All 10 Kafka broker tests (the 8 new ones plus the 2 existing v6 ones) passed 3 runs in a row against a local Kafka 4.3.1, taking about 40 seconds per run. No topics were left behind.
    • A clean build of the solution has 0 warnings, and all 215 unit tests pass.

Not in this increment

  • Use<Transport>() and header support for ActiveMQ and NetMQ.
  • Exposing Kafka fetch and batch tuning in KafkaOptions.

🤖 Generated with Claude Code

Adds UseKafka(IConfiguration) and UseKafka(Action<KafkaOptions>) for AddObvs,
with startup checks for missing brokers or serializer. UseKafka creates the
service's topics if they don't exist, since consumers don't. Each source gets
its own consumer group, so every bus in a process receives every message.
Messages reach subscribers on the task pool, so disposing the bus from a
subscriber no longer deadlocks. Message headers, including trace context,
travel as Kafka headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@christopherread christopherread added this to the v7 milestone Oct 25, 2026
@christopherread christopherread self-assigned this Oct 25, 2026
@christopherread
christopherread merged commit 0f5481e into v7 Oct 25, 2026
1 check passed
@christopherread
christopherread deleted the v7-kafka branch October 25, 2026 20:57
christopherread added a commit that referenced this pull request Oct 25, 2026
MessageSource called observer.OnNext on its consume thread, the task
started by Task.Run in Messages. Disposing the subscription cancels the
token and then waits for that task, so a subscriber that disposed the bus
or its subscription from a message callback waited on itself forever.
Messages are now handed to subscribers on the task pool, in order.

TestMessageSendAndReceive.ShouldNotDeadlockWhenASubscriberDisposesItsSubscription
disposes the subscription from a message callback; the message arrived but
the dispose timed out before this change.

Backport of the fix in v7 PR #119; the same fix for RabbitMQ was #113.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
christopherread added a commit that referenced this pull request Oct 25, 2026
* Add v7 'AI native' design doc (#97)

* Add v7 'AI native' design doc

Proposes how v7 changes configuration, message registration, handlers,
testing, errors, observability, contracts, packaging and docs so AI
coding agents (and people) can use Obvs correctly first time. Each
change links to its tracking issue in the v7 milestone (#84-#95).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Design doc: frame the async APIs as layers over the observable core

Section 4 now explains why v7 stays observable-first: handlers, RequestAsync
and IAsyncEnumerable are thin adapters over IObservable, Rx stays first
class for stream composition, and analyzer OBVS006 catches Subscribe(async).
Resolves the open question on Rx: it stays in the core package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Design doc: describe the bus lifetime under dependency injection

Section 1 now covers how the singleton bus is registered and owned, scoped
handlers, repeated AddObvs calls, keyed buses, and why one bus per
container needs a per-bus instance ID in broker-side names instead of the
process ID used by v6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* NetMQ: run each subscription's receive loop on its own thread (#110)

MessageSource started its receive loop with Task.Run. The loop blocks for
as long as the subscription lasts, so each subscription held a thread-pool
thread, and Subscribe waited until the loop got one. On CI's small runners,
the NetMQ tests start a dozen subscriptions at once; the pool added threads
slowly, subscribers took over 5 seconds to connect, publishers stopped
waiting for them, and the first messages were dropped. The loop now runs as
a LongRunning task on a dedicated thread.

TestMessageSourceThreading saturates the thread pool and checks that
subscribing doesn't wait for it. It failed before this change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* RabbitMQ: deliver messages to subscribers off the consumer-dispatch thread (#113)

MessageSource passed each message to subscribers synchronously on
RabbitMQ.Client's consumer-dispatch thread. Code running after a message
arrived, including code after awaiting one (the continuation runs inline
there), could deadlock it: disposing the bus closes the connection, which
waits for that same thread. Messages are still deserialized on the
dispatch thread, while the body's buffer is valid, then handed to
subscribers on the task pool, in order.

TestServiceBus.ShouldNotDeadlockWhenASubscriberDisposesTheBus disposes the
bus from a message callback; it timed out before this change.

Backport of the fix in v7 PR #112.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* CI: pin the .NET SDK to 10.0.402 (#117)

Microsoft's download origin returns 409 for the new SDK 10.0.403 and
runtime 10.0.14, so setup-dotnet fails to install 10.0.x. Go back to
10.0.x once that release downloads.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Kafka: deliver messages to subscribers off the consume thread (#120)

MessageSource called observer.OnNext on its consume thread, the task
started by Task.Run in Messages. Disposing the subscription cancels the
token and then waits for that task, so a subscriber that disposed the bus
or its subscription from a message callback waited on itself forever.
Messages are now handed to subscribers on the task pool, in order.

TestMessageSendAndReceive.ShouldNotDeadlockWhenASubscriberDisposesItsSubscription
disposes the subscription from a message callback; the message arrived but
the dispose timed out before this change.

Backport of the fix in v7 PR #119; the same fix for RabbitMQ was #113.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* ActiveMQ: deliver messages to subscribers off the session's dispatch thread (#122)

MessageSource passed each message to subscribers synchronously on the NMS
session's dispatch thread. Code that ran after awaiting a message could
then run on that thread, and deadlock it by disposing the bus: stopping
the connection waits for the dispatch thread, forever. NMS skips that
wait only for code in the dispatch's own execution context, which an
await continuation isn't. Messages are now handed to subscribers on the
task pool, in order.

TestServiceBus.ShouldNotDeadlockWhenCodeAfterAwaitingAMessageDisposesTheBus
hung before this change. Five TestMessageSource tests now wait for
delivery instead of expecting it on the listener's thread. CI runs the
ActiveMQ broker tests against apache/activemq-classic.

Backport of the fix in v7 PR #121.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant