Queues and topology¶
A RabbitQueue descriptor names the queue a handler consumes and describes what that queue is
expected to look like: durability, queue type, exchange bindings, prefetch, and raw x-*
arguments. A descriptor sits directly in the #[subscriber(...)] decorator:
use ruststream_lapin::{AMQPValue, Delay, LapinBroker, QueueType, RabbitExchange, RabbitQueue};
#[derive(Debug, Deserialize)]
struct OrderPlaced {
id: u64,
}
// One queue, fed by a topic exchange: every `order.*` event lands here. The queue survives
// restarts (durable is the default) and dead-letters rejected messages.
#[subscriber(RabbitQueue::new("orders")
.queue_type(QueueType::Quorum)
.bind(RabbitExchange::topic("events"), "order.*")
.dead_letter_exchange("dead-letters")
.prefetch(16))]
async fn on_order(event: &OrderPlaced) -> HandlerResult {
println!("order event {}", event.id);
HandlerResult::Ack
}
The bare-string form #[subscriber("orders")] is shorthand for RabbitQueue::new("orders"):
a durable, shared, non-auto-delete queue consumed as-is.
Declaration is an opt-in¶
Descriptors describe the EXPECTED topology. By default nothing is created on the broker: a missing queue is a subscribe error, because managing infrastructure is the user's job, not the framework's. A service that owns its queues opts in per broker:
#[ruststream::app]
fn app() -> impl App {
// declare_topology is off by default; this service owns its queues, so it opts in.
// default_queue_type applies to descriptors that do not pick a type themselves.
let broker = LapinBroker::new("amqp://localhost:5672")
.declare_topology(true)
.default_queue_type(QueueType::Quorum)
.prefetch(64);
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
b.include(on_order);
b.include(on_bounded);
b.include(on_charge);
})
}
With declaration enabled, subscribing declares the bound exchanges (except the built-in amq.*
ones and the default exchange), the queue, and the bindings, in that order. Declaration is
idempotent as long as the descriptor matches what exists; AMQP refuses to redeclare an entity
with different properties (PRECONDITION_FAILED).
Queue types¶
RabbitMQ picks the queue implementation at declaration time via x-queue-type, and the type of
an existing queue can never change. The descriptor exposes it as a typed option:
.queue_type(QueueType::Classic)- the classic single-node implementation..queue_type(QueueType::Quorum)- Raft-replicated; must stay durable (the crate rejects a quorum descriptor marked non-durable instead of letting the broker fail the declare).
A broker-wide .default_queue_type(..) applies to descriptors that do not pick a type; with
neither set, no x-queue-type is sent and the server default applies.
Note that RabbitMQ 4 denies transient (non-durable) non-exclusive queues by default: keep the
durable default unless the queue is .exclusive(true).
Prefetch¶
.prefetch(n) on the broker sets the per-subscription basic.qos window: at most n
deliveries in flight unacknowledged. This is the back-pressure valve for the subscriber stream -
consuming slower slows the broker's pushes instead of buffering without bound. A descriptor
overrides it per queue with .prefetch(n). Without either, the server imposes no limit.
Delivery metadata¶
AmqpContext carries the AMQP delivery metadata that is not part of the payload or the
headers: the exchange, the routing key, the redelivered flag, and the channel-local delivery
tag. Each field has a zero-sized key in context::keys, readable two ways: as an extractor
parameter (Ctx(routing_key): Ctx<RoutingKey> - the key names its context, so no ctx
parameter is needed), or through a declared ctx: &mut Context<'_, AmqpContext> parameter
with ctx.context(KEY):
// Delivery metadata arrives as extractor parameters: each key names the context it reads, so
// the handler declares exactly the fields it needs and no ctx parameter at all.
#[subscriber(RabbitQueue::new("orders-audit"))]
async fn audit(
order: &Order,
Ctx(routing_key): Ctx<RoutingKey>,
Ctx(redelivered): Ctx<Redelivered>,
) -> HandlerResult {
println!(
"order {} came via {routing_key} (redelivered: {redelivered})",
order.id
);
HandlerResult::Ack
}
Keyed worker lanes¶
A subscription can dispatch across several worker lanes with workers(n, by_key), keeping
deliveries that share a key on the same lane (ordered per key, parallel across keys):
// Eight lanes, keyed: two orders for the same tenant never process concurrently (per-tenant
// ordering), while different tenants run in parallel. `by_key` reads the partition key the
// producer set on the message.
#[subscriber(RabbitQueue::new("orders"), workers(8, by_key))]
async fn on_order(order: &Order) -> HandlerResult {
println!("order {} for tenant {}", order.id, order.tenant);
HandlerResult::Ack
}
The key comes from the PARTITION_KEY_HEADER (amqp-partition-key): the producer sets it on the
outgoing message's headers, and the crate reads it back through the Partitioned capability.
AMQP itself does not interpret the header, so it is a pure client-side convention - unrelated to
server-side hash routing (see the consistent-hash exchange below).
Dead-letter¶
.dead_letter_exchange("dlx") (plus optionally .dead_letter_routing_key(..)) sets the queue's
native dead-letter target. A handler that drops a message settles with
basic.reject(requeue = false), which routes it there; no extra machinery is involved.
Delayed retry¶
A handler that returns HandlerResult::retry_after(delay) asks for redelivery no sooner than
delay - the not-ready-yet case, where an immediate requeue would just spin. By default the
runtime handles this with its broker-agnostic fallback (the delayed copy waits in the service
process, at-most-once over the window). .delay(..) makes it native instead: the message parks
in a broker waiting queue with a per-message TTL and dead-letters back to the origin queue when
the TTL fires, so the delayed copy lives on the broker.
// `.delay(..)` makes `retry_after` native: a delayed message parks in a broker waiting queue for
// the delay, then dead-letters back here - durable, off the service process. The waiting queue
// (`charges.retry` by default) is declared under `declare_topology`.
#[subscriber(RabbitQueue::new("charges").delay(Delay::dlx_ttl()))]
async fn on_charge(event: &OrderPlaced) -> HandlerResult {
if event.id == 0 {
// Not ready yet: come back in 30s instead of spinning on an immediate requeue.
return HandlerResult::retry_after(Duration::from_secs(30));
}
HandlerResult::Ack
}
The waiting queue (<queue>.retry by default, or Delay::dlx_ttl_named(..)) is infrastructure:
it is declared only under declare_topology(true), otherwise provision it yourself. Because a
classic queue only releases expired messages from its head, use one waiting queue per delay class
(or a quorum queue) when delays vary widely.
For workloads with widely mixed delays, the plugin-dme feature offers Delay::plugin_dme(),
which routes redeliveries through the
delayed-message-exchange plugin
instead: the message carries an x-delay header and the plugin holds each one independently, so a
short delay never waits behind a long one. It needs the plugin enabled on the broker, hence the
feature gate.
Consistent-hash exchange (plugin)¶
For server-side fan-out - spreading one stream across several queues by hashing the routing key -
the plugin-consistent-hash feature exposes RabbitExchange::consistent_hash(..), lowering to the
rabbitmq_consistent_hash_exchange
plugin's exchange type. Each queue binds with its integer weight as the routing key, and the
broker splits the hash space proportionally:
use ruststream_lapin::{LapinBroker, RabbitExchange, RabbitQueue};
#[derive(Debug, Deserialize)]
struct Order {
id: u64,
}
// Two shards bound to one consistent-hash exchange: the broker splits the hash space by weight
// (the binding key), so orders spread evenly and each key always lands on the same shard.
#[subscriber(RabbitQueue::new("orders-shard-a")
.bind(RabbitExchange::consistent_hash("orders-by-key"), "1"))]
async fn shard_a(order: &Order) -> HandlerResult {
println!("shard a: order {}", order.id);
HandlerResult::Ack
}
#[subscriber(RabbitQueue::new("orders-shard-b")
.bind(RabbitExchange::consistent_hash("orders-by-key"), "1"))]
async fn shard_b(order: &Order) -> HandlerResult {
println!("shard b: order {}", order.id);
HandlerResult::Ack
}
Consistent-hash routing happens on the broker (across queues); it is the server-side counterpart to client-side keyed worker lanes (across lanes in one consumer). Enable the plugin on the broker before using it; the feature is off by default because the plugin is not part of a stock RabbitMQ.
Raw arguments¶
Anything the descriptor does not model rides through verbatim:
// Anything the descriptor does not model rides through verbatim as a raw `x-*` argument.
#[subscriber(RabbitQueue::new("bounded")
.argument("x-message-ttl", AMQPValue::LongLongInt(60_000))
.argument("x-max-length", AMQPValue::LongLongInt(100_000)))]
async fn on_bounded(event: &OrderPlaced) -> HandlerResult {
println!("bounded order {}", event.id);
HandlerResult::Ack
}
AMQPValue and FieldTable are re-exported from the crate for exactly this purpose.