Kafka

Kafka

On this page

A partitioned log, wired into the three ports that can use one: broadcasting, sockets, and the queue.

KAFKA_BROKERS=kafka-1:9092,kafka-2:9092
KAFKA_GROUP=checkout
// Broadcasting over a topic instead of Redis pub/sub.
app.instance(Broadcasting::new(Arc::new(kafka::broadcaster(&config, Arc::clone(&client)))));

// ...and read back into this process's sockets, so a broadcast published on
// one replica reaches a browser connected to another.
relay::spawn(kafka::relay(&config, Arc::clone(&client)), SocketFanOut::new(rooms));

Enable it with the framework's kafka feature, or kafka-tls for a managed cluster. The client is pure Rust: nothing new to install and no CMake.

Kafka is a log, not a queue

Everything below follows from one fact, so it is worth stating plainly: a consumer does not remove what it reads. A partition is an append-only file and a reader is a cursor into it.

flowchart LR
    subgraph PART["topic "jobs", partition 0"]
        O12[12] --- O13[13] --- O14[14] --- O15[15] --- O16[16]
    end
    CUR["one cursor<br/><i>not a set of in-flight messages</i>"] -.-> O14
    HW[high watermark] -.-> O16

There is no "delete this one", no "make that one visible again", and no way to acknowledge the third message while the second is still being worked on. What you get in exchange: retention independent of consumption, ordering within a partition, and throughput that comes from partition count rather than locking.

The valuable consequence is many readers, uncoordinated. The broadcast that moved a browser is still on the topic for the analytics consumer, the audit log, and the service somebody writes next quarter — none of which have to exist yet, or be told about, or be deployed together. That is the actual reason a team with Kafka wants their events on it.

Broadcasting

Broadcasting::new(Arc::new(KafkaBroadcaster::new(client).on_topic("broadcasts")))

The same shape as the Redis broadcaster, and the same { event, data, socket } body on the wire, so a relay that reads one reads the other.

Channels are keys, not topics. Everything goes to one topic, keyed by channel name. A topic per channel would be wrong twice over: Kafka topics are cluster-level objects with partitions and replicas to provision, and private-orders.7 is not something anybody wants to provision. Keying by channel gets the property that matters anyway — every message for a channel lands on one partition, so a browser sees them in the order they happened.

Why not just use Redis pub/sub

Redis pub/sub is fire and forget: a subscriber that is not connected at the instant of the publish never learns it happened, and nothing records that it did. That is the right trade for "make the list move" and the wrong one when the same event is also an order shipping or a payment clearing.

If the only consumer is a browser, Redis is simpler and you should use it.

Sockets across replicas

WebSockets says a Rooms registry lives in one process's memory, so two replicas behind a load balancer have two sets of rooms. A relay is how that stops being true:

flowchart TD
    REQ[request on replica A] --> B[Broadcasting] --> T[kafka topic]
    T --> RA[relay on replica A] --> SA[its sockets]
    T --> RB[relay on replica B] --> SB[its sockets]
relay::spawn(
    KafkaRelay::new(Arc::clone(&client), "broadcasts"),
    SocketFanOut::new(Arc::clone(&rooms))
        .naming_rooms(|channel| Some(channel.trim_start_matches("private-").to_string())),
);

Every replica publishes and every replica reads, including the one that published. No second deployment: the relay runs inside the web process, next to the sockets it feeds. The feed scenario is this arrangement end to end.

The relay has no cursor, deliberately

It reads from the end of the log and commits nothing, so it behaves like pub/sub — every replica sees what is published while it is running, and a replica that restarts does not replay yesterday's broadcasts to whoever happens to be connected now.

That is right for pushing to a browser and wrong for anything that must not be missed. If a message matters, it wants a job.

to_others needs a socket identity

// on_connect — tell the browser what to send back in X-Socket-ID
socket.send_json(&json!({ "socket_id": socket.identity() }))?;

SocketId is a per-process counter, so every replica has a socket 7. Sending the bare number means "everyone except 7" silences an unrelated browser on each of the other replicas. identity() pairs the counter with a per-process id, and a relay that sees another replica's identity skips nobody — which is the correct answer, not a fallback, because that socket is not here.

Jobs

QUEUE_DRIVER=kafka

Read this section before choosing it. The database driver is a better job queue and needs no new infrastructure. Kafka is right when the jobs are already events on a topic, or when per-key ordering matters more than concurrency.

The port saysKafka's answerSo the driver
reserve one joba cursorowns partitions with a lock, one job in flight each
acknowledge itadvance the cursorcommits offset + 1
release it for laternothingre-produces it, at the end of the topic
fail itnothingproduces to {topic}.failed
how many are waitingwatermark − cursorreports the lag
clear the queueyou cannot deleteskips to the end, and says how many

Concurrency is the partition count. Two workers cannot share a partition — a cursor is one number — so a topic with six partitions supports six concurrent jobs and a seventh worker waits. That is Kafka's model, not a limitation of the driver.

A delayed job blocks its partition. release(job, 30s) puts the job at the end of the topic, and a worker that reaches a job which is not due stops reading that partition until it is. In a queue the delayed job steps aside; in a log there is nowhere to step aside to.

A retry loses its place in the order. It is a new record at the tail, which is the standard Kafka retry-topic behaviour and worth knowing if you chose Kafka for the ordering.

Ownership and cursors live in the cache

This client does not join a consumer group, so partition ownership is a lock and the cursor is a cache entry. Both need the shared, lock-capable store you already run for on_one_server, and the constructor refuses one that is not:

KafkaQueue::new(client, locks)?     // Err on a process-local lock store

Because the silent version of that mistake is every worker owning every partition and every job running on every machine.

The cost of this choice is that kafka-consumer-groups.sh cannot see the lag — the cursors are Rainier's, not the broker's. size() reports it instead.

Attempts are counted, because Kafka does not count them

Kafka redelivers from a cursor that never moved and says nothing about having done so. Without a count of its own, a job that kills its worker is redelivered forever and the partition never moves again — the poison pill that takes a queue down at 3am and explains nothing.

So an attempt is what the record carries plus how many times this offset has been handed out. The first half survives a retry (a new record at a new offset); the second half counts a crash.

The lease must outlive the job

KafkaQueue::new(client, locks)?.with_lease(Duration::from_secs(300))

The lease is what stops a second worker reading the partition. If it expires while a job is still running, another worker takes the partition and runs from a cursor that has not moved — so the job in flight runs twice. Same advice as SQS's visibility timeout, same failure.

Events onto a topic

kafka::publish_events::<OrderShipped>(&events, client, "orders", |e| e.order_id.to_string());

Every event of that type becomes a record: the event name in a header so a consumer can route without parsing, the event itself as the body, keyed by whatever identifies the subject — which is what puts everything about one order on one partition, in order.

A failed publish is logged and swallowed. A listener returning Err stops the listeners behind it, and "the analytics topic was unreachable" is not a reason to abandon the rest of what an event set off. Where the publish must happen, publish from a job so it retries.

Configuration

KAFKA_BROKERScomma-separated bootstrap brokers. A bare host gets :9092
KAFKA_GROUPwhich set of cursors this deployment shares
KAFKA_TOPIC_PREFIXprefixes every topic produced to or read from
KAFKA_BROADCAST_TOPICwhere broadcasts go — broadcasts
KAFKA_TLSneeds the kafka-tls feature
KAFKA_USERNAME / KAFKA_PASSWORDSASL, when the cluster wants it
KAFKA_SASL_MECHANISMplain, scram-sha-256, scram-sha-512

The brokers listed are bootstrap brokers: the client asks one of them for the cluster's metadata and then talks to whichever broker leads each partition. One is enough to work and not enough to survive that broker being down, which is why it is a list.

A misspelled mechanism stops the boot rather than defaulting to PLAIN, since the default would send the password in the clear.

Everything has a deadline

KafkaConnector::parse(&brokers).with_timeout(Duration::from_secs(10))

Ten seconds by default, and it is a wall clock. The underlying client's own retry deadline counts accumulated sleep rather than elapsed time, which measured out at 145 seconds to give up on a "10 second" timeout against a machine with nothing listening. Inside a request that is not a slow failure, it is a worker that never comes back.

A fetch gets its budget plus the long-poll wait it asked for, so an idle consumer is not mistaken for a stuck one.

Topics are not created for you

create_topic exists and the queue does not call it. Topic creation is where partition counts and replication factors get decided, and a service that creates its own topics on boot decides them by accident — one partition, one replica, and the discovery of that during an incident.

Create them with your cluster's tooling, including {topic}.failed for the queue's dead letters.

When create_topic is used — a development cluster, a test — it waits for the cluster to report the topic before returning. The controller accepting it is not the cluster knowing about it, and a produce in that window is told the topic does not exist, so "created" would otherwise mean something a caller cannot act on.

No C toolchain

The wire client is rskafka, which is pure Rust. The obvious alternative wraps librdkafka and needs a C compiler and CMake on every machine that compiles the workspace — including the ones that will never speak to Kafka. TLS is rustls and SASL is pure Rust, for the same reason.

What that costs is stated under what is not here.

Testing

The parts that can be checked without a broker are: the record a broadcast or a job becomes, the topic and cursor names, the partition a key belongs on, and the attempt arithmetic. Those are unit tests.

Everything else is a claim about the log, so it is an integration test that skips unless a broker answers:

docker run --rm -p 9092:9092 apache/kafka:3.9.0
KAFKA_BROKERS=localhost:9092 cargo test --features kafka

CI runs a broker and sets KAFKA_REQUIRED=1, which turns "no Kafka, so skip" into a failure — a suite that silently skipped in CI would be a driver nobody had ever run, reported as passing.

What is not here

No consumer group. No JoinGroup, no heartbeat, no broker-side rebalancing — so nothing Rainier reads is visible to kafka-consumer-groups.sh, and partition ownership is a lock instead. A group-coordinating client is a rebalance state machine, and carrying one that is subtly wrong is worse than not carrying one.

No transactions and no exactly-once. Delivery is at-least-once, so a job must be idempotent — which is true of every driver here, and truer of this one.

No compression. The compression codecs available to a pure-Rust build are either C bindings or a partial implementation, and a producer that silently wrote uncompressed records to a topic configured for zstd would be worse than one that never offered it.

No schema registry, no Avro, no Protobuf. The body is bytes; what they mean is an application's decision. A registry is a service with its own configuration, cache and failure modes, and it belongs in a crate that is about that.

This page is the framework’s docs/kafka.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.