Design notesProposal
Provider Plan: RabbitMQ (full) and Kafka (connection & resource shape)
Plan. RabbitMQ shipped in a smaller form. Kafka was not built.
This is a design note, not current behaviour. It was written while the feature was being designed, and much of it shipped differently or not at all. For how Bitween works today, start with the documentation overview.
Companion to external-brokers-architecture.md. Baseline
origin/v2. Numbers marked (verify) are library defaults to re-check against the exact
package version pinned at implementation time; they are stated because the design depends on
them, not because they should be trusted unread.
Part 1 — RabbitMQ provider#
1.1 Principle: model the broker, not SW.Bus#
SimplyWorks.Bus is an opinionated application bus built on RabbitMQ: it owns naming, invents
.retry/.bad queues, routes by .NET message-type name, and assumes one exchange per
environment. Every one of those opinions is correct for a microservice bus and wrong for a
gateway into a client's existing broker, where the queue is called ORDERS.INBOUND because
someone decided that in 2014.
So SW.Bitween.DataSources.RabbitMq takes a dependency on RabbitMQ.Client directly and does
not reference SW.Bus at all. Concretely, what it deliberately does not inherit:
| SW.Bus behaviour | Provider behaviour |
|---|---|
queue name {env}.{app}.{consumer}.{messageType} |
the operator types the exact name; no prefix, no lowercasing, no derivation |
auto-declares .retry + .bad queues per consumer |
declares nothing unless topology says so; retries are DelayedRetry (§2.4 of the architecture doc) |
| routes on .NET type name → one exchange per environment | routing key, exchange, and headers are configuration; any exchange type |
AutomaticRecoveryEnabled left on (client-side recovery) |
off — the supervisor owns reconnection (§1.8) |
publish = IPublish.Publish(name, json) |
publish = exchange + routing key + properties + confirms (§1.6) |
| one connection for the whole app | one connection per DataSource, one channel per subscription (§1.9) |
1.2 Connection settings — mirror AMQP, plus pass-through#
Field list follows ConnectionFactory / the AMQP URI spec rather than anything Bitween-shaped:
{
"hosts": ["rabbit-1:5672", "rabbit-2:5672"], // ordered; client tries each (cluster-aware)
"virtualHost": "/",
"username": "bitween", "password": "…", // secret ⇒ SettingsProtector
"authMechanism": "plain", // plain | external (mTLS) | (extensible)
"tls": {
"enabled": true, "serverName": "rabbit.client.com",
"clientCertPath": null, "clientCertPassphrase": null, // secret
"acceptablePolicyErrors": [], // explicit, never a blanket "trust all"
"version": "Tls12,Tls13"
},
"requestedHeartbeatSeconds": 60,
"requestedFrameMax": 0, // 0 = broker default (128 KiB)
"requestedChannelMax": 2047,
"connectionTimeoutMs": 30000,
"clientProvidedName": "bitween/{node}/{dataSource}", // shows in the management UI
"managementUrl": null, // optional; enables Browse + lag reporting
"clientProperties": { } // free-form pass-through
}
Two deliberate choices:
clientProvidedNameis templated and defaults to something identifying. When a client's DBA asks "what is this connection", the management UI must answer. Costs nothing, saves an incident call.acceptablePolicyErrorsis an explicit list, never a boolean. "Ignore certificate errors" as a checkbox is how a self-signed cert quietly becomes a MITM-tolerant production connection.
1.3 Topology — mirror definitions.json, support everything#
The topology model is a subset of RabbitMQ's own export format, so an operator can paste from
rabbitmqadmin export or the management UI's definitions file:
{
"exchanges": [
{ "name": "orders", "type": "topic", "durable": true, "autoDelete": false,
"internal": false, "arguments": { "alternate-exchange": "orders.unrouted" } }
],
"queues": [
{ "name": "ORDERS.INBOUND", "durable": true, "exclusive": false, "autoDelete": false,
"arguments": {
"x-queue-type": "quorum", // classic | quorum | stream — all allowed
"x-dead-letter-exchange": "orders.dlx",
"x-dead-letter-routing-key": "inbound.failed",
"x-max-length": 100000, "x-overflow": "reject-publish",
"x-message-ttl": 86400000, "x-max-priority": 10,
"x-single-active-consumer": true,
"x-quorum-initial-group-size": 3
} }
],
"bindings": [
{ "source": "orders", "destination": "ORDERS.INBOUND", "destinationType": "queue",
"routingKey": "order.created.*", "arguments": {} },
{ "source": "orders", "destination": "orders.audit", "destinationType": "exchange",
"routingKey": "#", "arguments": {} }
]
}
Non-negotiables for "not opinionated":
- All exchange types, including plugin ones.
direct,fanout,topic,headers, plusx-consistent-hash,x-delayed-message,x-random,x-modulus-hash— the type is a free-text string, not an enum, because a plugin can define one we've never heard of. Validate by attempting the declare and surfacing the broker's error, not by rejecting unknown strings. argumentsis an open map, passed through verbatim with correct AMQP type coercion (integers aslong,x-matchas string, nested tables). Never a fixed field list — thex-*argument space grows every RabbitMQ release.- Exchange-to-exchange bindings (
destinationType: "exchange") are first class. Every hand-rolled integration forgets these and then can't model a real client topology. - Headers exchanges need
x-match: all|anyplus arbitrary header keys in binding arguments — which falls out of the open-map rule.
1.4 Declare mode — the field that decides whether this works at clients#
"declareMode": "none" | "assert" | "create"
| Mode | Behaviour | When |
|---|---|---|
none |
touch nothing; just consume/publish | Bitween has only read/write on the vhost — the common case at a client |
assert |
QueueDeclarePassive / ExchangeDeclarePassive; fail fast with a clear error if missing |
verify the client actually created what they promised, without needing configure |
create |
full declare + bind, idempotent | Bitween owns the topology |
This single field is what makes the provider usable against brokers we don't administer. A
provider that always declares will fail with ACCESS_REFUSED at exactly the wrong moment — and
worse, a declare with mismatched arguments returns PRECONDITION_FAILED and kills the
channel, which is an easy way to look broken while being correct.
1.5 Inbound binding#
{
"queue": "ORDERS.INBOUND",
"consumerTag": "", // "" ⇒ broker-generated
"prefetchCount": 20, // REQUIRED (§1.10) — the memory bound
"prefetchSize": 0,
"exclusive": false, // exclusive consumer (≠ exclusive queue)
"arguments": { "x-priority": 5, "x-stream-offset": "last" },
"noAck": false, // exposed, defaulted false, warned in UI
"contentEncoding": null // optional override when the publisher lies
}
Notes that matter: prefetch is per-channel, so one channel per subscription is required to
give each its own prefetch (§1.9). x-stream-offset is how stream queues are consumed —
supported by virtue of arguments being open. noAck: true is exposed because some telemetry
feeds genuinely want it, but it breaks the persist-then-ack guarantee, so the UI must say so.
1.6 Outbound binding#
{
"exchange": "orders", // "" ⇒ default exchange (publish straight to a queue)
"routingKey": "order.created.{{partner.region}}", // token-templated (§11.5)
"mandatory": false, // true ⇒ surface basic.return as a publish failure
"confirms": true, // publisher confirms; wait per-publish or per-batch
"confirmTimeoutMs": 5000,
"properties": {
"deliveryMode": 2, // 1 transient | 2 persistent
"priority": null, "expiration": null, "contentType": "application/json",
"headers": { "x-tenant": "{{partner.code}}" }
}
}
mandatory + confirms together are the only way to know a publish landed. Without them a
publish to a nonexistent routing key succeeds silently — a failure mode that looks exactly like
success in every log. Default confirms: true for egress, and treat a nack or a
basic.return as a transport failure feeding the outbox retry, not an Xchange error.
1.7 Capabilities#
new ProviderCapabilities(
CanManageTopology: true, CanBrowseTopology: true, // Browse requires managementUrl
SupportsHeaders: true, SupportsOrderingKey: false, // routing key ≠ ordering key
SupportsPartitions: false,
SupportsNack: true, SupportsRequeueDelay: false, // no native delay without the plugin
SupportsNativeDeadLetter: true, // x-dead-letter-exchange
SupportsExclusiveConsumer: true, // exclusive consumer / x-single-active-consumer
SupportsCompetingConsumers: true,
SupportsTransactions: false, // tx.* is slow; use confirms instead
SupportsBatch: true, MaxMessageBytes: 134_217_728);
SupportsRequeueDelay becomes true when the rabbitmq_delayed_message_exchange plugin is
detected — SW.Bus already probes for it (DelayedPluginAvailable), so reuse the technique:
probe once at connect, cache on the connection, report through capabilities. Capabilities are
therefore per connection, not per provider type — worth making the contract return them from
IDataSourceConnection, not only IDataSourceProvider.Describe().
1.8 Reconnection: the supervisor owns it#
Set AutomaticRecoveryEnabled = false and TopologyRecoveryEnabled = false. Reasons:
- Two recovery mechanisms fight. The supervisor already reconciles on fault (§5), and
client-side recovery silently re-declares topology that
declareMode: nonesays not to touch. - Client-side recovery hides state:
IMessageSubscription.Stateand reconnect counts (§10.3) would be lies. - Recovery policy belongs with placement — on a
Leader-placed subscription, a dropped connection may mean another node should take over, not that this one should reconnect.
Instead: ConnectionShutdown / CallbackException / consumer Shutdown → mark Degraded,
raise Faulted, let the supervisor back off (exponential, jittered, capped) and reconcile.
1.9 Connection and channel topology inside the provider#
- One
IConnectionperDataSource. Not per subscription — a connection is the expensive object; channels are cheap. - One
IModel/IChannelper subscription. Required for per-subscription prefetch, and channels are not thread-safe, so sharing one across consumers is a data race waiting for load. - One dedicated publish channel per (connection, endpoint) for egress, because
confirm_selectis a channel-level mode and mixing confirmed publishes with consumer acks on one channel makes the confirm bookkeeping ambiguous. - Never publish from a consumer's channel.
1.10 Resource footprint — RabbitMQ#
The dominant term is unacknowledged messages, and it is entirely under our control:
peak inbound memory ≈ Σ over subscriptions ( prefetchCount × avg message size )
A prefetch of 500 on a queue of 2 MB payloads is 1 GB of managed memory on one node, from one
config field. Hence: prefetchCount is a required field in the descriptor with a sane default
(10–20) and an explicit upper bound, and the UI shows the computed worst case
(prefetch × observed **max** size) next to it. That single affordance prevents most of the
plausible OOMs.
Calibrate against the measured baseline in §5.2 of the architecture doc: real payloads run ~5 KB average with a ~1 MB maximum, and ingest acks in ~330 ms. At those sizes prefetch is cheap (20 × 5 KB = 100 KB) and the bound should be set by the maximum payload, not the average — prefetch 500 × 1 MB is 500 MB, prefetch 20 × 1 MB is 20 MB. Don't be timid with prefetch for ingest; the downstream work-group queue is the shock absorber, not the broker.
Per-connection and per-channel costs (verify against the pinned version):
| Item | Rough cost | Notes |
|---|---|---|
IConnection |
tens of KB managed + socket buffers; frame buffers scale with negotiated frame_max (128 KiB default) |
broker side also pays ~100 KB+ per connection |
| TLS | + SslStream buffers, order of 32–64 KB per connection |
plus handshake CPU at connect/reconnect |
IModel per subscription |
small — hundreds of bytes to low KB | not a scaling concern |
| Threads (client 6.8.1) | one dedicated socket read loop per connection, plus a heartbeat timer; consumer callbacks dispatched on the async work service | 25 data sources ⇒ ~25 dedicated threads before any work happens |
CPU is negligible at rest — heartbeats every 60 s and frame parsing proportional to throughput. The two real CPU events are TLS handshakes during reconnect storms (bounded by the backoff in §1.8) and JSON deserialization in Bitween's own pipeline, which dwarfs the AMQP cost.
Client version decision. SW.Bus pins RabbitMQ.Client 6.8.1 (IModel,
DispatchConsumersAsync). The provider loads in its own AssemblyLoadContext (§4.1), so it
can independently target RabbitMQ.Client 7.x — IChannel, fully async, task-based I/O
instead of a thread per connection, and a better allocation profile. Two managed versions
coexisting in one process is exactly what the ALC isolation buys, and there is no native
dependency to conflict. Recommendation: build the provider on 7.x and treat the thread
savings as the headline reason. If 7.x proves troublesome, 6.8.1 works — but then budget one
thread per data source and lower MaxDataSourceConnectionsPerNode accordingly.
1.11 Test-connection stages#
Map to the staged result from §11.2 — each stage is a distinct RabbitMQ failure the operator fixes differently:
| Stage | Check |
|---|---|
dns / tcp |
resolve + connect to each host in hosts, report which succeeded |
tls |
handshake; report cert subject, expiry, and the specific policy error |
auth |
CreateConnection — distinguish ACCESS_REFUSED (credentials) from vhost-not-found |
authorize |
probe the three permissions separately: read (passive-declare the queue), write (publish to a nonexistent routing key on a topic exchange with mandatory:false), configure (declare a temporary auto-delete queue, then delete) — and report which are held. This is what tells an operator that declareMode: create will fail before they save. |
topology |
assert/create dry run: passive-declare everything the endpoint references |
Also report negotiated frame_max, channel_max, server version, and detected plugins
(delayed message, consistent hash) — all cheap, all useful, all invisible otherwise.
1.12 Cluster discovery via the management plugin#
Difficulty: low, and the payoff is the best demo surface in the whole feature. The management
plugin is a plain HTTP+JSON API (:15672, :15671 for TLS) with HTTP basic auth. A typed client
over the eight endpoints below is roughly a day's work; the interesting design is entirely in
degradation and scale, not in the calls.
Endpoints that carry the whole experience (verify shapes against the cluster's version — the management API is stable but has grown fields across 3.8 → 4.x):
| Endpoint | What it unlocks |
|---|---|
GET /api/overview |
cluster name, RabbitMQ + Erlang version, exchange_types (see below), listeners, rates mode |
GET /api/nodes |
per-node memory/disk alarms, fd/socket usage, partition status — "is this cluster healthy" |
GET /api/vhosts |
vhost picker instead of a free-text field |
GET /api/exchanges/{vhost} · GET /api/queues/{vhost} |
the browsable topology, with live depth/rates per queue |
GET /api/bindings/{vhost} |
the routing graph (and the routing preview, below) |
GET /api/whoami + GET /api/permissions/{vhost}/{user} |
the configure/write/read regex triple for this user |
GET /api/definitions/{vhost} |
the entire topology in the exact shape §1.3 already models |
GET /api/aliveness-test/{vhost} |
broker-side end-to-end check for the health panel |
The five features worth building
-
Build the exchange-type dropdown from the live cluster.
/api/overviewreturnsexchange_types— what this broker actually supports, including plugin types likex-consistent-hashandx-delayed-message. This is the perfect answer to "don't be opinionated": no hardcoded list, no guessing about plugins, and it self-updates when the client installs a plugin. Same trick replaces the separate delayed-plugin probe in §1.7. -
Import instead of retype.
GET /api/definitions/{vhost}returns exchanges, queues, and bindings in the structure §1.3 deliberately mirrors — so "select these three queues and two exchanges → save as this endpoint's topology" is a filter over a JSON document, not a translation layer. This is the single biggest reason the definitions-shaped topology model was the right choice. -
Drift and conflict detection — the highest-value item. With live definitions, the
TopologyDifffromITopologyManager.Planstops being a guess: show exactly whatApplywould create, what already matches, and — critically — what exists with different arguments. Argument mismatch is the failure that returnsPRECONDITION_FAILEDand kills the channel (§1.4); catching it in a diff before saving turns the nastiest RabbitMQ failure mode into a UI warning. -
Preflight warnings computed from real numbers, not guesses.
GET /api/queues/{vhost}/{name}returnsmessages,message_bytes,consumers,consumer_details,consumer_utilisation, and rates. Two warnings fall straight out:- Memory: average message size is
message_bytes / messages, so the prefetch estimate in §1.10 becomesprefetchCount × actual observed averageinstead of a hand-waved number. - Competing consumers: if the queue already has consumers, say so plainly — Bitween will
compete with them for messages, or be silently starved if the queue has
x-single-active-consumer. This is a real trap that is invisible without discovery, and "why does Bitween only get half the messages" is otherwise a day of debugging.
- Memory: average message size is
-
Routing preview, computed locally. There is no route-simulation endpoint, but with the bindings in hand it is ~40 lines: for
directcompare routing keys, fortopicmatch*/#, forfanouttake all. Then the outbound form can say "routing keyorder.created.joreachesORDERS.INBOUND,ORDERS.AUDIT" — or, far more usefully, "reaches no queue; this message will be silently discarded", which is exactly the failuremandatory/confirms exists to catch (§1.6). Be honest about the limit:headers,x-consistent-hash, and other plugin exchanges can't be simulated — show "cannot preview for this exchange type" rather than a wrong answer.
Optional, high value, needs a policy decision
POST /api/queues/{vhost}/{name}/get peeks messages without consuming (with
ackmode=reject_requeue_true). That would let the Information-type screen seed promoted
properties from a real message — a genuinely great onboarding flow. But it reads the client's
production payloads into Bitween's UI, so it needs: an explicit permission, an audit entry, a
count=1 cap, requeue mode forced, and never running automatically on page load. Worth building;
worth not building casually.
What makes it hard (none of it is the code)
- Management access is a different grant from AMQP access. The HTTP API requires a user with
a management tag (
monitoring/management/administrator); AMQPread/write/configurepermissions grant nothing there. Many clients will hand over AMQP credentials and no management user at all. So:managementUrlstays optional (§1.2),CanBrowseTopologyis a per-connection capability, and every discovery feature degrades to the AMQP-only path — you can't list, butdeclareMode: assertcan still verify a name via passive declare. Design the UI so discovery is an accelerator, never a prerequisite. - The HTTP port is a separate firewall hole. 5672 open does not imply 15672 open. Report this as its own test-connection stage so "discovery unavailable" is distinguishable from "wrong password".
- Scale.
GET /api/queueson a cluster with thousands of queues returns megabytes and costs the broker real work;/api/bindingsis worse. Use the list endpoints' pagination and column projection (page,page_size,name,use_regex,columns=) from the first commit, never fetch-all-then-filter-in-JS, and always scope by vhost. Also honour the user'sreadregex when presenting results, so the picker doesn't offer queues they can't consume. - Stats can be absent. Metrics collection can be disabled or lag on a busy cluster; treat
depth/rate fields as nullable and show "unavailable" instead of
0, which reads as "empty queue" and is a much worse lie. - Where it runs. Discovery must execute on a node that can reach the management port — same argument as test-connection in §11.2 of the architecture doc. Route it through the supervisor, not the API node that happens to serve the request.
Sizing
| Piece | Effort |
|---|---|
| Typed management client (8 endpoints, pagination, auth, timeouts) | ~1 day |
| Browser UI: vhost → exchanges/queues tree, live columns, "use as endpoint" | 2–3 days |
| Definitions import + diff/drift panel | 1–2 days |
| Routing preview + preflight warnings | ~1 day |
| Message peek (incl. permission + audit) | ~1 day |
Roughly a week and a half on top of the provider, entirely additive — every piece can ship
after the provider works, and the provider is fully functional with none of it. Note the v2 UI has
no graph/diagram library (only lucide-react icons), so render the binding graph as an indented
list or a small hand-rolled SVG rather than adding a dependency for it.
1.13 Build order#
| Step | Deliverable |
|---|---|
| 1 | Project SW.Bitween.DataSources.RabbitMq, IDataSourceProvider + descriptor (connection fields, topology schema, inbound/outbound binding), no I/O |
| 2 | Connect + staged health check (§1.11) + capability probe (plugins, server version) |
| 3 | Subscribe: channel per subscription, prefetch, AsyncEventingBasicConsumer (or 7.x async consumer) → InboundMessage; ack/nack from AckDecision; ProviderMetadata carries deliveryTag, exchange, routingKey, redelivered, headers |
| 4 | Fault plumbing: shutdown/callback events → Faulted + State, no client auto-recovery |
| 5 | ITopologyManager: Plan (diff against live definitions when the management API is available, passive declares otherwise), Apply (idempotent), Browse (management API when configured) |
| 6 | Publish: dedicated channel, confirms, mandatory + basic.return, templated routing key and headers |
| 7 | Integration tests (Testcontainers RabbitMQ, with and without the delayed plugin — SW.Bus's PluginAvailableTests/PluginUnavailableTests are the pattern): all four core exchange types + consistent-hash, e2e binding, quorum and stream queues, declareMode × permission matrix, PRECONDITION_FAILED on argument mismatch, kill-the-broker reconnect, prefetch honoured |
Part 2 — Kafka provider: connection and resource shape#
Deliberately scoped to what the user asked: what is hard about connecting, and what it costs in memory and CPU. Semantics (offsets, groups, ordering) are covered in the architecture doc.
2.1 Configuration: pass-through is the correct answer#
Do not model librdkafka's ~200 properties as fields. First-class the handful Bitween's behaviour depends on, and expose the rest as a validated pass-through map:
{
"bootstrapServers": "b1:9092,b2:9092",
"securityProtocol": "sasl_ssl", // plaintext | ssl | sasl_plaintext | sasl_ssl
"saslMechanism": "scram-sha-512", // plain | scram-sha-256/512 | gssapi | oauthbearer
"saslUsername": "…", "saslPassword": "…", // secret
"ssl": { "caLocation": null, "certificateLocation": null, "keyLocation": null,
"keyPassword": null, "endpointIdentificationAlgorithm": "https" },
"clientId": "bitween-{node}",
"config": { "socket.keepalive.enable": "true", "…": "…" } // free-form librdkafka pass-through
}
Guard the pass-through with a deny list, not an allow list: reject keys that would break the
ack model (enable.auto.commit — Bitween commits after persistence) and keys the provider owns
(group.id, bootstrap.servers, anything in the first-class set). Everything else goes through,
because the next client will need sasl.oauthbearer.token.endpoint.url or a broker-version
override and should not need a Bitween release to get it.
Event Hubs note: its Kafka endpoint is sasl_ssl + PLAIN with username $ConnectionString
and the connection string as password. That is a configuration of this provider, which is the
whole point of the §8.2 sequencing — try it before writing an Event Hubs provider.
2.2 Connection challenges#
- A "connection" is not a connection.
Confluent.Kafkawraps native librdkafka; a consumer handle opens a socket to every broker it learns about, not just the bootstrap. Your connection count is a function of the client's cluster size, which you don't control. - Failures are asynchronous and log-shaped. There is no
Connect()to await. A wrong password surfaces as an error event (ErrorCode.SaslAuthenticationFailed) some time after construction. So test-connection must be implemented asAdminClient.GetMetadata(timeout)with an error-handler subscription, not as "did the constructor throw". Budget a real timeout (10 s) and treat "no metadata yet" as failure with the collected error text. - Handle disposal is slow. Closing a consumer performs a leave-group round trip and can take seconds. The supervisor's reconcile must dispose off the critical path, or a config save appears to hang.
- Rebalances make reconcile expensive — and this is the real interaction with the cluster
design. Every stop/start of a Kafka subscription triggers a consumer-group rebalance that
pauses all members. A flapping node or an over-eager reconcile becomes a rebalance storm.
Mitigations, all cheap, all needed:
- debounce reconcile per data source (e.g. coalesce for 5–10 s) instead of acting on every broadcast;
- set
group.instance.idfrom the stableNodeId(static group membership) so a restart insidesession.timeout.msdoes not rebalance; - prefer
partition.assignment.strategy=cooperative-stickyso a join doesn't stop the world; Placement = Allis the right default for Kafka — the consumer group already does the distribution, and forcingLeaderboth wastes nodes and makes every leadership change a rebalance.
- One handle per (data source, group), not per topic. A single consumer can subscribe to many topics; a handle per topic multiplies threads and sockets for nothing.
2.3 Memory — the headline risk#
librdkafka prefetches per partition, and the defaults are large (verify against the pinned version):
| Property | Default | Meaning |
|---|---|---|
queued.max.messages.kbytes |
65536 (64 MiB) | per partition local queue cap |
queued.min.messages |
100000 | messages to keep queued per partition |
fetch.message.max.bytes |
1048576 (1 MiB) | per-partition fetch request size |
fetch.max.bytes |
52428800 (50 MiB) | per fetch response, across partitions |
receive.message.max.bytes |
100000000 | hard per-response ceiling |
Worst case on defaults: partitions × 64 MiB. A 30-partition topic is ~2 GB of prefetch buffer for one subscription — on a node whose container limit might be 1 GB. This is not a hypothetical; it is the default configuration.
So: queued.max.messages.kbytes and queued.min.messages are required descriptor fields with
low defaults (start at 1024–4096 KiB and 1000), and the UI must show
partitions × queued.max.messages.kbytes as the reserved worst case. The provider should query
partition count at subscribe time and refuse — or loudly warn — when the product exceeds a
configured share of the node's ContainerMemoryLimitBytes (§10.4).
And it is native memory. librdkafka allocates outside the .NET heap, so
GC.GetTotalMemory() and Gen2 counts show nothing while the process grows.
Process.WorkingSet64 is the only metric in §10.2 that catches Kafka pressure — worth calling
out explicitly on the Nodes page, or an operator will conclude the node is healthy right up to
the OOM kill.
2.4 CPU and threads#
- Threads per consumer handle: 1 main/coordinator thread + 1 per broker connection + internal timers. A 3-broker cluster ⇒ roughly 5 native threads per handle. Ten Kafka subscriptions sharing one handle: still ~5. Ten separate handles: ~50. This is the argument for §2.2 item 5, in numbers.
- These are native threads, not thread-pool threads. Decompression (lz4/snappy/zstd) and CRC
validation happen on them, so Kafka CPU load is invisible to
ThreadPoolQueueLength— the one §10.2 metric that catches RabbitMQ-side starvation. Process CPU% is the signal for Kafka nodes. Both metrics are needed; neither is sufficient alone. - Compression is the main steady-state CPU cost.
zstdon a busy topic is materially more expensive thanlz4; exposecompression.codecfor egress and default tolz4. - Practical budget: assume a Kafka data source costs roughly an order of magnitude more
memory and several times more threads than a RabbitMQ one.
MaxDataSourceConnectionsPerNode(§10.4) should therefore be weighted per provider, not a flat count — a descriptor-declaredResourceWeight(rabbitmq: 1, kafka: 8, pulsar: 6) that the supervisor sums against a budget.
2.5 Discovery, the Kafka equivalent — different in kind#
Since Kafka is the less familiar one, the plain-language version: Kafka has no management plugin
and needs none. The equivalent of RabbitMQ's HTTP admin API is the Admin API carried over the
same binary protocol on the same port with the same credentials (AdminClient in
Confluent.Kafka). That is strictly easier than RabbitMQ: no second port, no second firewall
hole, no separate management user, no pagination.
What you can discover, and what it maps to:
| Kafka call | Gives you | RabbitMQ analogue |
|---|---|---|
GetMetadata |
brokers, cluster id, topics with partition counts, replicas, leaders | /api/overview + /api/queues |
DescribeConfigs |
per-topic config: retention, cleanup.policy, min.insync.replicas |
queue arguments |
ListConsumerGroups / DescribeConsumerGroups |
existing groups, their members and assignments | queue consumer_details |
ListConsumerGroupOffsets + QueryWatermarkOffsets |
committed offset vs high watermark ⇒ lag | queue depth |
DescribeCluster |
controller, broker endpoints | /api/nodes |
DescribeAcls |
permissions, when the user may read them | /api/permissions |
The structural difference to be clear about: there is nothing to browse. Kafka has no exchanges, no bindings, and no routing — a topic is a flat log, and "routing" is just the producer's choice of topic plus a partition key. So the binding graph, the routing preview, and the definitions import have no Kafka analogue at all. The equivalent smart view is a flatter thing: topics × partitions × lag × retention, plus which consumer groups already read each topic.
Two Kafka-specific warnings that are worth as much as the RabbitMQ ones:
- Group-id collision. In RabbitMQ, an existing consumer on the queue means Bitween competes
for messages. The Kafka mirror is sharper: consumer groups are independent, so reading a topic
another group already reads is harmless — but reusing a
group.idsomeone else is using silently steals their partitions.ListConsumerGroupsmakes this checkable at configuration time, and it should be a hard validation warning. - Topic auto-creation. If the broker allows it and
allow.auto.create.topicsis true, a typo in a topic name silently creates an empty topic and the subscription sits there consuming nothing forever. Default that setting to false in the provider and validate the topic exists via metadata instead.
Effort: ~2 days for the discovery client plus a topics/groups/lag panel — less work than RabbitMQ's, with less to show, because there is genuinely less structure in Kafka to reveal.
2.6 Minimal build order (see also Part 3 for the test environment)#
| Step | Deliverable |
|---|---|
| 1 | Descriptor + config validation (first-class fields, deny-listed pass-through), no I/O |
| 2 | AdminClient.GetMetadata staged health check with error-event capture; report cluster id, broker count, topic/partition counts |
| 3 | Consumer handle per (data source, group); subscribe many topics; manual commit after persistence; ProviderMetadata = topic, partition, offset, timestamp, headers |
| 4 | Resource guards: required queue-size fields, partition-count × buffer preflight against the node budget, ResourceWeight |
| 5 | Reconcile hygiene: debounce, group.instance.id from NodeId, cooperative-sticky, async disposal |
| 6 | Lag reporting for §10.3 via committed vs high-watermark |
| 7 | Producer for egress: lz4, acks=all, enable.idempotence=true, delivery-report callbacks feeding the outbox |
| 8 | Integration tests on Redpanda (fast, Kafka-protocol compatible): consume/commit/restart-resume, rebalance on second node, buffer cap honoured, Event Hubs config path validated against a real namespace |
Part 3 — Local dev and integration testing#
3.1 The actual CI situation#
CI is GitHub Actions (.github/workflows/bitween-api-cicd-gateway.yml), delegating to the
org-wide reusable workflow simplify9/.github/.github/workflows/reusable-service-cicd.yml@main.
azure-pipelines.yml is not the active pipeline. The repo is public, so GitHub-hosted
ubuntu-latest gives 4 vCPU / 16 GB RAM / 14 GB SSD — roughly double a private-repo runner,
and generous for this workload. Resource pressure is therefore not the deciding factor;
startup seconds are, because they are paid on every run.
Two findings that matter more than any container sizing:
- Integration tests do not run in CI today. The workflow passes
test-projects: 'SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj'—SW.Bitween.IntegrationTestsis never executed by any pipeline. Every container-cost estimate below is therefore hypothetical until someone decides to run them. - There is no PR gate. Triggers are
pushonreleases/**plusworkflow_dispatch, so pushes tov2— the branch this feature is being built on — run nothing at all.
So the real decision is not "which Kafka image" but "do we add a PR workflow that runs the
integration suite?" Recommendation: add a separate pr-tests.yml on pull_request +
push: [v2] running unit and integration tests. Without it, every broker provider ships with
tests that only ever run on a developer's laptop — which for a feature whose whole risk surface is
live connections and reconnection behaviour is the wrong trade. The reusable release workflow
should stay as-is; this is an additive, low-risk second workflow.
Today's suite (SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs:33-34) starts
PostgreSqlBuilder + RabbitMqBuilder once per collection ([CollectionDefinition("Bitween")]),
so the pattern for adding brokers already exists and already scales the right way.
Side note, relevant to §1.12: the deploy step already injects
Bitween__RabbitMqManagementUrl / Username / Password as secrets, so a management-API
connection is already a first-class configured dependency in production (it backs the Ops
queue-health pages). The DataSource design simply moves that from one per install to one per
connection.
3.2 Is Kafka dockerized? Yes — and there are lighter protocol-compatible options#
| Option | Image | RSS (approx) | Cold start | Verdict |
|---|---|---|---|---|
| Redpanda (C++, no JVM, no ZK) | redpandadata/redpanda |
~150–250 MB tuned | ~3–5 s | Recommended for CI. Kafka-protocol compatible, single binary |
| Apache Kafka, KRaft mode (JVM) | apache/kafka |
~600–800 MB with -Xmx512m |
~15–25 s | Most faithful reference. Keep as an opt-in "conformance" job |
| Apache Kafka native image | apache/kafka-native |
~150–250 MB | ~1–3 s | Very promising — GraalVM build, dev/test targeted. (verify maturity for the version you pin) |
| Confluent | confluentinc/cp-kafka / confluent-local |
~800 MB–1 GB | ~20–30 s | Heaviest; no advantage here |
| Azure Event Hubs emulator | mcr.microsoft.com/azure-messaging/eventhubs-emulator |
~1–1.5 GB (needs an Azure SQL Edge sidecar) | ~30 s+ | Don't use for CI. Two containers, and Kafka-endpoint support is a documented limitation (verify against the current release) |
Kafka no longer needs ZooKeeper. KRaft mode has been production-ready since 3.3, so any modern image is a single container — the two-container Kafka+ZK setup people remember is obsolete.
Redpanda needs explicit flags or it will eat the agent. It is thread-per-core by design and defaults to claiming all cores and a large memory reservation, which even on a 4-vCPU runner starves the test host. Non-negotiable CI flags:
--overprovisioned --smp 1 --memory 512M --reserve-memory 0M --node-id 0 --check=false
Testcontainers.Redpanda sets sane defaults, but verify these are applied — this single item is
the difference between a 4-second start and a pegged agent.
Event Hubs strategy (consistent with §8.2 of the architecture doc): test the protocol against Redpanda, and validate the Event Hubs configuration path once against a real namespace as a manual/nightly step. Do not try to emulate Event Hubs in the PR suite.
3.3 How the community handles this#
Testcontainers is the answer, and it is what you already do. Java, .NET, Go, and Node all
converge on it; the .NET modules Testcontainers.Redpanda and Testcontainers.Kafka exist
alongside the PostgreSql/RabbitMq ones already in use (bump all Testcontainers packages
together — the repo is on 3.10.0).
Worth knowing: .NET has no embedded Kafka. Java has spring-kafka-test /
EmbeddedKafkaCluster for in-process brokers; there is no equivalent for .NET, so a container is
the only honest option. That is a real difference from RabbitMQ testing, where SW.Bus's existing
suite already proves the container path is fine.
The four practices that keep this cheap — and the traps they avoid:
- One broker for the whole suite, not per test class. This is the mistake: a broker per fixture turns a 2-minute suite into 20. The existing collection fixture already does it right; keep new brokers in the same fixture.
- Isolate with unique names, not fresh containers. Per-test
topic-{guid}/group-{guid}(and per-test queue names for RabbitMQ) gives full isolation at zero startup cost. Never restart a broker to get a clean slate. - Pre-create topics via
AdminClient; never rely on auto-create. Auto-create hides the exact typo bug §2.5 warns about, and produces flaky "consumer sees nothing" failures. .WithReuse(true)for the local dev loop so an inner loop doesn't pay startup on every run. Leave it off in CI, where a clean agent is the point.
3.4 CI budget#
| Suite | Containers | RAM | Added cold start |
|---|---|---|---|
| Today | Postgres + RabbitMQ | ~200–350 MB | ~10–20 s |
| + external RabbitMQ provider | reuse the same RabbitMQ — but see §3.5 | +0 | +0 |
| + Kafka provider (Redpanda tuned) | +1 | ~+200 MB | ~+5 s |
+ MQTT (eclipse-mosquitto) |
+1 | ~+15 MB | <1 s |
| + Pulsar standalone | +1 | ~+1–2 GB | ~+30–60 s |
Everything except Pulsar fits the 16 GB / 4-vCPU runner easily; the full set including Redpanda and Mosquitto lands around 600 MB and adds well under 10 seconds of startup.
Pulsar is the outlier and needs a planning decision now, because it is tier-1 for the Tuya demo (§8.2). Pulsar standalone is a JVM cluster in one container — BookKeeper plus broker plus ZooKeeper-equivalent — and on a shared agent its startup alone can exceed the current entire suite. Recommendation: put Pulsar (and later IBM MQ) tests in a separate opt-in pipeline job that runs nightly or on-label, not in the PR suite. Keep the PR suite at Postgres + RabbitMQ + Redpanda + Mosquitto.
3.5 Two concrete gotchas in the current setup#
-
The RabbitMQ discovery feature needs a different image — and SW-Bus already shows how. There is nothing to install and no plugin-enabling step to automate: the official
rabbitmq:*-managementimage variants ship withrabbitmq_managementpre-enabled. It is a one-line image override, not a provisioning problem. SW-Bus's own integration tests already do exactly this:SW-Bus test Image What it proves PluginUnavailableTests.cs:19rabbitmq:3.13-managementofficial -managementvariant = management API available out of the boxPluginAvailableTests.cs:20heidiks/rabbitmq-delayed-message-exchange:3.13.0-managementeven a non-bundled community plugin (delayed message) is solved by a prebuilt image — no .ezdownload, norabbitmq-plugins enable, no custom DockerfileSo Bitween's fixture is simply the odd one out:
new RabbitMqBuilder().Build()takes the module's default image, which has no management plugin, so every §1.12 endpoint (/api/overview,/api/definitions,/api/queues) would return nothing. Fix:.WithImage("rabbitmq:4-management")and expose15672. Budget ~+50–100 MB and a slightly slower readiness probe.Switch the shared fixture rather than adding a second container — the management API is also the most convenient way to assert topology in every RabbitMQ test, not just the discovery ones.
Two caveats worth a decision:
heidiks/…is a third-party image. Fine for a test-only dependency, but pin it by digest rather than tag, or replace it with a three-line in-repo Dockerfile (FROM rabbitmq:4-management+COPYthe.ez+rabbitmq-plugins enable) built through Testcontainers'ImageFromDockerfileBuilder. For the provider, the delayed-plugin path only matters if we choose to exposeSupportsRequeueDelay(§1.7); the outbox/DelayedRetrypath doesn't need it.- SW-Bus pins 3.13; the official images are on 4.x. Align Bitween's fixture with whatever the discovery code is developed against, since management-API response fields have grown across 3.8 → 4.x (§1.12).
-
The test process itself is a memory factor for Kafka.
librdkafkabuffers are native and per-partition (§2.3), so a test that subscribes to a multi-partition topic on defaults can allocate hundreds of MB inside the test host. Set tiny buffers in test config (queued.max.messages.kbytes=1024,queued.min.messages=100) — and add one test that asserts the provider's preflight guard rejects an oversized configuration, so the guard rail from §2.4 is covered rather than assumed.
3.6 Local dev environment#
A docker-compose.dev.yml worth committing, because developing the discovery and Kafka features
without UIs is miserable:
| Service | Why |
|---|---|
postgres |
app + Quartz store |
rabbitmq:4-management (5672 + 15672) |
the management UI is both the dev tool and the thing §1.12 talks to |
redpanda (tuned flags) |
Kafka endpoint |
redpanda-console |
topics/groups/lag browser — the closest analogue to RabbitMQ's management UI, and effectively required to develop the Kafka provider |
eclipse-mosquitto |
~15 MB, add it now for the MQTT phase |
Two notes: the compose file should use the same image tags as the Testcontainers fixtures so dev and CI don't diverge on broker version, and Redpanda Console is dev-only — never a dependency of the test suite.
3.7 Answering the "is it easy" question directly#
Kafka's local/test story is easier than its runtime story. Getting a broker up is a solved problem: one tuned Redpanda container, ~5 seconds, ~200 MB, using the exact Testcontainers pattern this repo already runs. The genuinely hard parts of Kafka are the ones in §2.2–2.4 — asynchronous connection failures, rebalances interacting with reconcile, and per-partition native buffers — and notably none of those are made easier or harder by the choice of container. So the test environment should not influence the provider sequencing: pick Redpanda, spend the saved effort on the rebalance and buffer-guard tests, which is where the real risk lives.