Topics and groups¶
A subscription is one consumer joining one consumer group on one topic. KafkaTopic is the
descriptor: everything besides the topic name is optional, and unset options mean the
librdkafka defaults - this crate does not impose its own.
// Everything besides the topic is optional: unset options mean the librdkafka defaults. The
// group overrides the broker-wide `default_group`; `Commit::Tracked` turns `ack` into a precise
// per-message acknowledgement backed by the stored position; `config` reaches any consumer
// property this crate does not surface as a typed option.
#[subscriber(
KafkaTopic::new("orders")
.group("orders-workers")
.start(StartOffset::Earliest)
.commit(Commit::Tracked)
.assignment(Assignment::CooperativeSticky)
.config("fetch.min.bytes", "1024"),
publish("confirmations")
)]
async fn confirm(order: &Order) -> Confirmation {
Confirmation {
id: order.id,
accepted: true,
}
}
#[ruststream::app]
fn app() -> impl App {
let broker = KafkaBroker::new(["localhost:9092"]);
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
let confirmations = TypedPublisher::new(b.broker().publisher());
b.include_publishing(confirm, confirmations);
b.include(audit_partition_zero);
})
}
Consumer groups¶
Kafka cannot subscribe without a group, so every subscription needs one from somewhere:
KafkaTopic::group per subscription, or KafkaBroker::default_group for everything that does
not name its own (including the bare-string #[subscriber("orders")] form). A subscription
that ends up with no group at all is a clear startup error, not a silent default.
Start offsets¶
StartOffset maps to librdkafka's auto.offset.reset and applies when the group has no valid
committed offset for a partition: it has never committed one, or the committed offset was
deleted by retention / is out of range (which is how a long-idle group on the default latest
reset skips to the end instead of reprocessing):
Committed(default) - leave the choice to librdkafka (its default resets to the latest offset).Earliest- start from the earliest retained offset.Latest- only messages published after the group formed.
Commit modes¶
Kafka commits one position per partition instead of settling individual messages. The Commit
mode picks how ack and nack map onto that model:
Commit::Auto(default, the librdkafka behavior): positions are stored as messages are handed to the application and committed everyauto.commit.interval.ms.ackandnackare advisory no-ops. A crash can lose the tail of processed-but-uncommitted work or skip unprocessed deliveries that were already stored.Commit::Tracked: precise at-least-once.enable.auto.offset.storeis switched off, and anackadvances the stored position to just below the lowest still-unsettled delivery, so out-of-order acks (concurrent worker lanes) never commit past an unprocessed message, and offset gaps the consumer never receives (transaction markers, compacted-away records) cannot block the position. Auto-commit still flushes the stored position in the background and once more when the consumer closes.Commit::Transactional("pipeline-id"): exactly-once. The consumer never commits its own offsets - theEosPipelinewith the matching transactional id commits them inside the producer transaction, atomically with the records the handlers publish.ackadvances the shared watermark exactly likeTracked; see Exactly-once pipelines.
Negative settlement under Tracked:
nack(false)(drop) settles the offset so the position can move past it.nack(true)(requeue) leaves the offset unsettled: the committed position stays below it, so Kafka redelivers from there when the partition is next fetched (a rebalance or a restart). The unsettled offset also blocks the watermark, keeping every later ack uncommitted until then - precise, but worth knowing when a handler nacks in a loop. Retry topics, seek-back redelivery, and dead-letter routing are planned descriptor options.
Multiple topics and patterns¶
One subscription can consume several topics through one consumer and one group. All matched topics share the handler, and therefore its payload type; each delivery still reports the topic it came from:
// `and_topic` adds topics to the same subscription: one consumer joins the group for both.
#[subscriber(KafkaTopic::new("orders").and_topic("cancellations").group("orders-svc"))]
async fn on_order_event(event: &OrderEvent) -> HandlerResult {
println!("order event {}", event.id);
HandlerResult::Ack
}
For open-ended sets, a librdkafka topic regex subscribes to every matching topic - the pattern
must start with ^ (librdkafka's anchor for distinguishing patterns from literal names):
// A `^`-anchored librdkafka regex subscribes to every matching topic; topics created later are
// picked up on the next metadata refresh.
#[subscriber(KafkaTopic::pattern("^audit\\..*").group("audit-svc").start(StartOffset::Earliest))]
async fn on_audit(event: &OrderEvent) -> HandlerResult {
println!("audit event {}", event.id);
HandlerResult::Ack
}
The in-process test broker supports multi-topic subscriptions (exact-name routing per topic) but not patterns.
Partition assignment¶
KafkaTopic::assignment picks how the group balances partitions across members (librdkafka's
partition.assignment.strategy): Assignment::Range, Assignment::RoundRobin, or
Assignment::CooperativeSticky for incremental rebalancing where unaffected partitions keep
flowing during a rebalance. Unset means the librdkafka default (range,roundrobin).
Cooperative and eager strategies cannot mix within one group, and librdkafka offers no API for
custom group assignors.
Manual partition assignment¶
KafkaTopic::partitions switches the subscription from the group protocol (subscribe) to
manual assignment (assign): the consumer takes exactly the named partitions of the topic -
no group membership, no rebalancing. It is the honest answer to "consume exactly these
partitions": static pinning, inspection and replay readers, one-consumer-per-partition
deployments.
// Manual assignment: consume exactly these partitions - no group membership, no rebalancing.
// This reader names no group, so it cannot commit and the start offset must be explicit; add
// `.group("...")` to commit positions into a group without joining it.
#[subscriber(
KafkaTopic::new("orders")
.partitions([0])
.start(StartOffset::Earliest)
)]
async fn audit_partition_zero(order: &Order) -> HandlerResult {
println!("partition 0 saw order {}", order.id);
HandlerResult::Ack
}
A group is optional and changes only offset handling. With one named, the consumer commits
into the group without joining it: Commit::Tracked stores positions exactly as on a normal
subscription, and StartOffset::Committed resumes from them. Without one, commits are off and
the start offset must be explicit (Earliest or Latest); Commit::Tracked and
StartOffset::Committed are clear startup errors, and acks are advisory (librdkafka insists
on a group.id even for assign(), so a group-less reader runs under the inert
ruststream.standalone placeholder - it never joins or commits). Manual assignment
does not combine with and_topic/pattern (it names exact partitions of one topic) or with
Commit::Transactional, and the in-process test broker rejects it (it does not simulate
partitions).
Manual assignment composes with keyed worker lanes out of the box: under the default
LaneKey::Partition each assigned partition lanes independently, so
partitions([0, 2, 5]) + workers(n, by_key) processes every assigned partition in order on
its lane. Sizing n against the partition list is your call - fewer lanes than partitions
means partitions share lanes (ordering still holds), more means idle lanes.
Keyed worker lanes¶
Kafka partitions by the native record key, and this crate surfaces it through
IncomingMessage::partition_key (with the Partitioned capability mirroring it), so keyed
lanes keep per-key ordering end to end:
// Eight lanes by record key (the opt-in): two orders for the same tenant never process
// concurrently (per-tenant ordering), while different tenants run in parallel - even inside
// one partition.
#[subscriber(
KafkaTopic::new("orders").group("orders-workers").lane_key(LaneKey::RecordKey),
workers(8, by_key)
)]
async fn on_order(order: &Order) -> HandlerResult {
println!("order {} for tenant {}", order.id, order.tenant);
HandlerResult::Ack
}
KafkaTopic::lane_key picks what drives the lanes. The default, LaneKey::Partition, lanes
by the source partition - Kafka's native ordering unit, so everything one partition delivers
(keyless included) processes in order on one lane, and concurrency comes from consuming
several partitions:
// The default lanes by the source partition, Kafka's own ordering unit: everything one
// partition delivers (keyless audit events included) processes in order on one lane.
#[subscriber(KafkaTopic::new("audit").group("audit-workers"), workers(8, by_key))]
async fn on_audit(order: &Order) -> HandlerResult {
println!("audit entry {} for tenant {}", order.id, order.tenant);
HandlerResult::Ack
}
LaneKey::RecordKey opts into finer, per-record-key lanes (the first example above):
deliveries sharing a record key stay ordered while different keys of one partition process
concurrently. Keyless deliveries then carry no lane key and rotate across lanes, losing even
their partition order.
Retries and dead-lettering¶
Without a policy, nack(true) keeps Kafka's native meaning: the offset stays unsettled and
redelivers on the next fetch of the partition. KafkaTopic::retry gives it an immediate one:
Retry::Topic("orders.retry")republishes the message to the retry topic with an attempt counter riding in thekafka-retry-countheader, then settles the original (republish-first: a crash between the steps duplicates, never loses).Retry::SeekBackseeks the partition back and re-consumes the message in place; everything after it on that partition replays too, and the attempt count survives only within the session.Retry::Droptreatsnack(true)like the drop path.
max_deliveries(n) is the poison cap (the original delivery counts as one): once the next
retry would exceed it, the drop path runs instead. dead_letter("orders.dlq") routes the drop
path - nack(false) included - to a dead-letter topic, stamped with kafka-dlq-source-topic /
-partition / -offset headers, then settles; without it the drop path just settles. Retry
and dead-letter topics are your infrastructure: the crate only publishes to them. The
in-process test broker does not run these policies (nack(true) re-enqueues in place there).
The usual pipeline puts the retry topic on the same subscription with and_topic, so retried
copies come back to the same handler:
// `and_topic` puts the retry topic on the same subscription, so retried copies come back to
// this very handler: a fresh delivery arrives from "payments", each retry from
// "payments.retry" with the attempt count riding in the `kafka-retry-count` header. Once the
// next retry would exceed `max_deliveries`, the drop path dead-letters the message instead of
// republishing it again.
#[subscriber(
KafkaTopic::new("payments")
.and_topic("payments.retry")
.group("payments-svc")
.commit(Commit::Tracked)
.retry(Retry::Topic("payments.retry".into()))
.max_deliveries(5)
.dead_letter("payments.dlq")
)]
async fn charge(payment: &Payment, State(gateway): State<PaymentGateway>) -> HandlerResult {
if payment.amount_cents <= 0 {
// Malformed input is not worth retrying: `drop()` takes the drop path straight to the
// dead-letter topic.
return HandlerResult::drop();
}
match gateway.charge(payment).await {
Ok(()) => HandlerResult::Ack,
// A transient failure: republish to "payments.retry" and settle the original, so the
// partition keeps flowing past it.
Err(err) => {
eprintln!("payment {} failed: {err}; retrying", payment.id);
HandlerResult::retry()
}
}
}
Retry::SeekBack trades throughput for strict partition order - nothing overtakes a failed
message while it retries:
// `Retry::SeekBack` re-consumes the failed delivery in place: redelivery is immediate and the
// partition order holds (nothing overtakes the failed message), at the price of replaying
// everything after it on that partition and of the attempt count only surviving within the
// session.
#[subscriber(
KafkaTopic::new("ledger")
.group("ledger-svc")
.commit(Commit::Tracked)
.retry(Retry::SeekBack)
.max_deliveries(3)
.dead_letter("ledger.dlq")
)]
async fn post_entry(payment: &Payment) -> HandlerResult {
println!("posting ledger entry for payment {}", payment.id);
HandlerResult::Ack
}
A dead-letter consumer is an ordinary subscription; the kafka-dlq-source-* headers carry the
origin of the failed delivery:
// The dead-letter consumer: dropped messages arrive stamped with the origin of the failed
// delivery in the `kafka-dlq-source-*` headers, so alerting can trace them back without
// parsing payloads.
#[subscriber(KafkaTopic::new("payments.dlq").group("payments-dlq").start(StartOffset::Earliest))]
async fn on_dead_letter(payment: &Payment, ctx: &mut Context<'_>) -> HandlerResult {
let source = ctx
.headers()
.get_str(DLQ_SOURCE_TOPIC_HEADER)
.unwrap_or("<unknown>");
println!("payment {} dead-lettered from {source}", payment.id);
HandlerResult::Ack
}
Batches¶
Batching is native: the subscriber implements the core BatchSubscriber capability directly,
and a page is one delivery plus everything librdkafka has already fetched - no added waiting,
no crate-imposed knobs. Page size is bounded by librdkafka's own fetch-queue limits
(queued.max.messages.kbytes and friends, reachable through the raw config passthrough):
// The `batch(..)` marker wraps the usual source: batching is native - a page is one delivery
// plus everything librdkafka has already fetched, bounded by librdkafka's own fetch-queue
// limits. Returning one HandlerResult settles the whole page uniformly. `workers(4)` keeps up
// to four pages in flight at once; `by_key` does not apply to batches (a keyed policy here
// behaves like a plain pool - per-key lanes exist for single-message handlers, see the keyed
// lanes example).
#[subscriber(
batch(KafkaTopic::new("orders").group("orders-svc").commit(Commit::Tracked)),
workers(4)
)]
async fn handle_page(orders: &[Order]) -> HandlerResult {
let first = orders.first().map(|order| order.id);
println!(
"processing a page of {} orders, first id {first:?}",
orders.len()
);
HandlerResult::Ack
}
Need an explicit page window instead? The core Buffered adapter wraps any source and closes
a page at max_size deliveries or max_wait after the first one (see the second handler in
the example below). Batch handlers mount with include_batch:
#[ruststream::app]
fn app() -> impl App {
let broker = KafkaBroker::new(["localhost:9092"]);
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
// Kafka has no native delayed redelivery: retry_after re-publishes a delayed copy
// through this publisher (and settles the original, so the committed position moves).
let retries = b.broker().publisher();
b.retry_via(retries);
b.include_batch(handle_page);
b.include_batch(reconcile_page);
})
}
How batch settlement maps onto Kafka¶
A batch handler settles its page either uniformly (one HandlerResult for every element) or
per element (Vec<HandlerResult> / Vec<Settle>, entry i settling element i):
// Per-element settlement: entry `i` settles page element `i`. This works, but read the docs on
// how it maps onto Kafka's one-position-per-partition commits: under `Commit::Tracked` the
// committed position only advances up to the first non-acked element, and `retry_after` runs
// through the runtime's deferred-republish fallback (`retry_via` below), not a native delay.
// Wrapping the source in the core `Buffered` adapter is the opt-in for an explicit
// size/deadline page window on top of the native batching.
#[subscriber(batch(
Buffered::<KafkaTopic>::new(
KafkaTopic::new("payments").group("payments-svc").commit(Commit::Tracked)
)
.max_size(50)
.max_wait(Duration::from_millis(20))
))]
async fn reconcile_page(payments: &[Payment]) -> Vec<HandlerResult> {
payments
.iter()
.map(|payment| {
if payment.settled {
HandlerResult::Ack
} else {
println!("payment {} not settled yet; retrying later", payment.id);
HandlerResult::retry_after(Duration::from_secs(30))
}
})
.collect()
}
Kafka commits one position per partition, not per message, so the outcomes map as follows
(everything below assumes Commit::Tracked; under Commit::Auto every settlement is an
advisory no-op and none of it applies):
- Uniform
Ack- works exactly as expected: the page settles, the position advances. - Per-element with
Acks only - also exact: acks may even land out of order across concurrent pages, the position always advances to just below the lowest unsettled delivery. - Per-element with a
retry()in the middle - the runtime does settle each element individually, but the committed position stops in front of the first retried element and stays there until that offset redelivers (the next fetch of the partition: a rebalance or a restart). When it does, the acked tail behind it replays too - at-least-once duplicates, not loss. Selective ack therefore works "up to the first nack" as far as the committed position is concerned; use a retry policy (retry topics) when a poison element must not hold the page hostage. - Per-element with
retry_after(..)- Kafka has no native delayed redelivery, so the runtime's deferred-republish fallback runs: withretry_via(publisher)configured on the scope, the element settles immediately (the position moves past it) and a copy republishes to the topic's tail after the delay - ordering is not preserved, and the copy is at-most-once across the delay window (a crash before the timer fires loses it). Withoutretry_viathe delay is dropped with a warning and the element degrades to a plainretry()hole as above. - A too-short result vector - the unmatched remainder of the page is retried (an extra redelivery beats a silently lost message) and the mismatch is logged.
Concurrency¶
workers(n) on a batch registration keeps up to n pages in flight at once; the tracked
position stays correct under out-of-order acks by construction. by_key does not apply to
batches - a keyed policy behaves like a plain pool of the same size. Per-key ordering is a
single-message-handler feature (workers(n, by_key), see the keyed lanes example), where it
composes with Kafka's native record-key partitioning end to end.
The in-process test broker batches natively the same way (a page drains what is enqueued),
and the Buffered wrapper works over both brokers.
Consume errors¶
The subscriber stream forwards consumer errors as stream items, except the ones librdkafka is
already retrying by itself - today exactly UnknownTopicOrPartition, a subscribed topic that
does not exist yet. Such an episode surfaces as one warning when it starts - the monitoring
signal to act on - with debug lines for the repeats and the recovery, so a topic that appears
late (broker auto-creation, provisioning races) recovers on its own without flooding the
dispatch error log, while a topic that never appears leaves the warning standing.
Raw configuration passthrough¶
KafkaTopic::config(key, value) reaches any librdkafka consumer property this crate does not
surface as a typed option (fetch.min.bytes, session.timeout.ms, isolation.level, ...). It
is applied last, so it wins over the typed options; overriding the keys a commit mode relies on
(enable.auto.offset.store) changes that mode's behavior accordingly.