ruststream_amqp/address.rs
1//! [`AmqpAddress`]: the subscription descriptor.
2//!
3//! `AMQP` 1.0 standardises the wire but not the meaning of an address, so addressing stays
4//! explicit: `queue` for anycast (competing consumers), `topic` for multicast (fan-out), and
5//! `raw` for deployments with their own convention. The queue/topic constructors advertise the
6//! matching terminus capability (`"queue"` / `"topic"`), which is how `ActiveMQ` Artemis and other
7//! products disambiguate; `raw` sends the address verbatim with no capability.
8
9use std::future::{Future, ready};
10use std::num::NonZeroU32;
11use std::time::Duration;
12
13#[cfg(feature = "asyncapi")]
14use ruststream::asyncapi::Bindings;
15use ruststream::{
16 AddressedCopies, RedeliveryAddress, RedeliveryAddressed, SubscriptionSource, nonzero,
17};
18
19#[cfg(feature = "asyncapi")]
20use crate::bindings;
21
22use crate::broker::ConnectedAmqpBroker;
23use crate::error::AmqpError;
24use crate::subscriber::AmqpSubscriber;
25
26/// Default protocol-level credit (prefetch) granted to a subscription.
27pub const DEFAULT_CREDIT: NonZeroU32 = nonzero!(256);
28
29/// Default deadline closing a partial batch on a batch subscription.
30pub const DEFAULT_BATCH_WAIT: Duration = Duration::from_millis(10);
31
32/// Delivery guarantee of a subscription.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum Settle {
35 /// Deliveries are settled by the handler: `ack` accepts, `nack` releases or rejects. The
36 /// default.
37 #[default]
38 AtLeastOnce,
39 /// Deliveries are settled on receipt; `ack`/`nack` report
40 /// [`AckError::Unsupported`](ruststream::AckError::Unsupported).
41 AtMostOnce,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum Kind {
46 Queue,
47 Topic,
48 Raw,
49}
50
51/// How the subscriptions on one address share its traffic: the terminus capability seen from the
52/// consuming end.
53///
54/// This is the difference between a work queue and a broadcast, so the in-process transport
55/// reproduces it instead of handing every message to everyone and hoping the deployment agrees.
56#[cfg(feature = "testing")]
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub(crate) enum Routing {
59 /// Competing consumers: each message goes to exactly one of the subscriptions.
60 Anycast,
61 /// Fan-out: every subscription gets its own copy.
62 Multicast,
63}
64
65/// A subscription descriptor for an `AMQP` 1.0 address.
66///
67/// Implements [`SubscriptionSource`], so it can sit inline in the `#[subscriber(..)]` decorator:
68///
69/// ```
70/// # mod demo {
71/// use ruststream_amqp::prelude::*;
72/// use serde::Deserialize;
73///
74/// #[derive(Deserialize)]
75/// struct Order {
76/// id: u64,
77/// }
78///
79/// #[subscriber(AmqpAddress::queue("orders").credit(nonzero!(64)))]
80/// async fn fulfil(order: &Order) -> HandlerOutcome {
81/// println!("fulfilling order {}", order.id);
82/// HandlerOutcome::ack()
83/// }
84/// #
85/// # #[ruststream::app]
86/// # fn app() -> impl App {
87/// # RustStream::new(AppInfo::new("orders", "0.1.0"))
88/// # .with_broker(AmqpBroker::new("amqp://localhost:5672"), |b| {
89/// # b.include(fulfil);
90/// # })
91/// # }
92/// # }
93/// # fn main() {}
94/// ```
95#[derive(Debug, Clone, PartialEq, Eq)]
96#[must_use]
97pub struct AmqpAddress {
98 address: String,
99 kind: Kind,
100 credit: NonZeroU32,
101 settle: Settle,
102 batch_wait: Duration,
103}
104
105impl AmqpAddress {
106 fn of(address: String, kind: Kind) -> Self {
107 Self {
108 address,
109 kind,
110 credit: DEFAULT_CREDIT,
111 settle: Settle::default(),
112 batch_wait: DEFAULT_BATCH_WAIT,
113 }
114 }
115
116 /// An anycast address: competing consumers, each message delivered to one of them.
117 ///
118 /// # Examples
119 ///
120 /// ```
121 /// # mod demo {
122 /// use ruststream_amqp::prelude::*;
123 /// use serde::Deserialize;
124 ///
125 /// #[derive(Deserialize)]
126 /// struct Order {
127 /// id: u64,
128 /// }
129 ///
130 /// // Every replica of the service competes for "orders": each order is handled once.
131 /// #[subscriber(AmqpAddress::queue("orders"))]
132 /// async fn fulfil(order: &Order) -> HandlerOutcome {
133 /// println!("fulfilling order {}", order.id);
134 /// HandlerOutcome::ack()
135 /// }
136 /// #
137 /// # #[ruststream::app]
138 /// # fn app() -> impl App {
139 /// # RustStream::new(AppInfo::new("orders", "0.1.0"))
140 /// # .with_broker(AmqpBroker::new("amqp://localhost:5672"), |b| {
141 /// # b.include(fulfil);
142 /// # })
143 /// # }
144 /// # }
145 /// # fn main() {}
146 /// ```
147 pub fn queue(name: impl Into<String>) -> Self {
148 Self::of(name.into(), Kind::Queue)
149 }
150
151 /// A multicast address: fan-out, each message delivered to every subscriber.
152 ///
153 /// # Examples
154 ///
155 /// ```
156 /// # mod demo {
157 /// use ruststream_amqp::prelude::*;
158 /// use serde::Deserialize;
159 ///
160 /// #[derive(Deserialize)]
161 /// struct Event {
162 /// kind: String,
163 /// }
164 ///
165 /// // Every service subscribed to "events" gets its own copy of each event.
166 /// #[subscriber(AmqpAddress::topic("events"))]
167 /// async fn audit(event: &Event) -> HandlerOutcome {
168 /// println!("audit: {}", event.kind);
169 /// HandlerOutcome::ack()
170 /// }
171 /// #
172 /// # #[ruststream::app]
173 /// # fn app() -> impl App {
174 /// # RustStream::new(AppInfo::new("orders", "0.1.0"))
175 /// # .with_broker(AmqpBroker::new("amqp://localhost:5672"), |b| {
176 /// # b.include(audit);
177 /// # })
178 /// # }
179 /// # }
180 /// # fn main() {}
181 /// ```
182 pub fn topic(name: impl Into<String>) -> Self {
183 Self::of(name.into(), Kind::Topic)
184 }
185
186 /// A verbatim address, for deployments with their own addressing convention
187 /// (`"/queues/orders"` on `RabbitMQ` 4.x, a fully qualified queue on Artemis).
188 ///
189 /// # Examples
190 ///
191 /// ```
192 /// # mod demo {
193 /// use ruststream_amqp::prelude::*;
194 /// use serde::Deserialize;
195 ///
196 /// #[derive(Deserialize)]
197 /// struct Order {
198 /// id: u64,
199 /// }
200 ///
201 /// // RabbitMQ 4.x addresses a queue by its v2 path; the string reaches the broker as written.
202 /// #[subscriber(AmqpAddress::raw("/queues/orders"))]
203 /// async fn fulfil(order: &Order) -> HandlerOutcome {
204 /// println!("fulfilling order {}", order.id);
205 /// HandlerOutcome::ack()
206 /// }
207 /// #
208 /// # #[ruststream::app]
209 /// # fn app() -> impl App {
210 /// # RustStream::new(AppInfo::new("orders", "0.1.0"))
211 /// # .with_broker(AmqpBroker::new("amqp://localhost:5672"), |b| {
212 /// # b.include(fulfil);
213 /// # })
214 /// # }
215 /// # }
216 /// # fn main() {}
217 /// ```
218 pub fn raw(address: impl Into<String>) -> Self {
219 Self::of(address.into(), Kind::Raw)
220 }
221
222 /// Sets the protocol-level credit (prefetch): how many unsettled deliveries the broker may
223 /// have in flight to this subscription. Defaults to [`DEFAULT_CREDIT`].
224 ///
225 /// The count is a [`NonZeroU32`] because a subscription granted no credit receives nothing:
226 /// zero is not a quieter setting but a stalled subscription, so it is unrepresentable here.
227 ///
228 /// # Examples
229 ///
230 /// ```
231 /// # mod demo {
232 /// use ruststream_amqp::prelude::*;
233 /// use serde::Deserialize;
234 ///
235 /// #[derive(Deserialize)]
236 /// struct Order {
237 /// id: u64,
238 /// }
239 ///
240 /// // At most 64 unsettled orders in flight to this service at once.
241 /// #[subscriber(AmqpAddress::queue("orders").credit(nonzero!(64)))]
242 /// async fn fulfil(order: &Order) -> HandlerOutcome {
243 /// println!("fulfilling order {}", order.id);
244 /// HandlerOutcome::ack()
245 /// }
246 /// #
247 /// # #[ruststream::app]
248 /// # fn app() -> impl App {
249 /// # RustStream::new(AppInfo::new("orders", "0.1.0"))
250 /// # .with_broker(AmqpBroker::new("amqp://localhost:5672"), |b| {
251 /// # b.include(fulfil);
252 /// # })
253 /// # }
254 /// # }
255 /// # fn main() {}
256 /// ```
257 ///
258 /// A zero is rejected while the service is compiled, not when the subscription opens:
259 ///
260 /// ```compile_fail
261 /// use ruststream::nonzero;
262 /// use ruststream_amqp::AmqpAddress;
263 ///
264 /// let source = AmqpAddress::queue("orders").credit(nonzero!(0));
265 /// # let _ = source;
266 /// ```
267 pub fn credit(mut self, credit: NonZeroU32) -> Self {
268 self.credit = credit;
269 self
270 }
271
272 /// Sets the delivery guarantee. Defaults to [`Settle::AtLeastOnce`].
273 pub fn settle(mut self, settle: Settle) -> Self {
274 self.settle = settle;
275 self
276 }
277
278 /// Caps how long a partial batch waits for more deliveries on a batch subscription, counted
279 /// from its first one. Defaults to [`DEFAULT_BATCH_WAIT`].
280 ///
281 /// `AMQP` 1.0 has no batch pull, so the batches are assembled on the client and this deadline
282 /// is what trades latency for fuller batches under a trickle of traffic. It has no effect on a
283 /// single-message subscription, where every delivery goes out as it arrives.
284 ///
285 /// # Examples
286 ///
287 /// ```
288 /// # mod demo {
289 /// use std::time::Duration;
290 ///
291 /// use ruststream_amqp::prelude::*;
292 /// use serde::Deserialize;
293 ///
294 /// #[derive(Deserialize)]
295 /// struct Order {
296 /// id: u64,
297 /// }
298 ///
299 /// // Up to 32 orders per call, or whatever arrived within 50 ms of the first one.
300 /// #[subscriber(AmqpAddress::queue("orders").batch_wait(Duration::from_millis(50)))]
301 /// async fn fulfil(orders: &[Order]) -> HandlerOutcome {
302 /// let ids: Vec<u64> = orders.iter().map(|order| order.id).collect();
303 /// println!("fulfilling orders {ids:?}");
304 /// HandlerOutcome::ack()
305 /// }
306 ///
307 /// #[ruststream::app]
308 /// fn app() -> impl App {
309 /// RustStream::new(AppInfo::new("orders", "0.1.0"))
310 /// .with_broker(AmqpBroker::new("amqp://localhost:5672"), |b| {
311 /// b.include(fulfil.batch(nonzero!(32)));
312 /// })
313 /// }
314 /// # }
315 /// # fn main() {}
316 /// ```
317 pub fn batch_wait(mut self, batch_wait: Duration) -> Self {
318 self.batch_wait = batch_wait;
319 self
320 }
321
322 /// The address string sent to the broker.
323 #[must_use]
324 pub fn address(&self) -> &str {
325 &self.address
326 }
327
328 pub(crate) fn credit_value(&self) -> u32 {
329 self.credit.get()
330 }
331
332 pub(crate) fn settle_value(&self) -> Settle {
333 self.settle
334 }
335
336 pub(crate) fn batch_wait_value(&self) -> Duration {
337 self.batch_wait
338 }
339
340 /// Where a publisher on this broker reaches this subscription again.
341 ///
342 /// One `AMQP` 1.0 node serves both roles: a receiver attaches its source to the address, a
343 /// sender its target. The address is therefore the answer for all three kinds, which is what
344 /// lets the framework's deferred `retry_after` fallback work on this broker without native
345 /// delayed redelivery. On a `queue` terminus the copy competes for consumers like any other
346 /// message, and on a `topic` terminus every subscriber sees it: that is the terminus the
347 /// declaration asked for, not a property of the retry.
348 fn redelivery_target(&self) -> RedeliveryAddress {
349 RedeliveryAddress::new(self.address.clone())
350 }
351
352 /// How this descriptor's terminus shares the address's traffic, for the in-process transport.
353 ///
354 /// `queue` competes, `topic` fans out. A `raw` address declares no capability, so on a server
355 /// the peer's own configuration decides; in process there is no configuration to consult, and
356 /// the transport delivers each message once rather than inventing a fan-out the deployment may
357 /// not have. A service that wants the broadcast asserted says `topic`, which is also what the
358 /// products needing the capability have to be told.
359 #[cfg(feature = "testing")]
360 pub(crate) fn routing(&self) -> Routing {
361 match self.kind {
362 Kind::Topic => Routing::Multicast,
363 Kind::Queue | Kind::Raw => Routing::Anycast,
364 }
365 }
366
367 /// The terminus capability this descriptor advertises, when one applies.
368 pub(crate) fn capability(&self) -> Option<&'static str> {
369 match self.kind {
370 Kind::Queue => Some("queue"),
371 Kind::Topic => Some("topic"),
372 Kind::Raw => None,
373 }
374 }
375
376 /// What this subscription tells a reader of the generated document: the node it reads, the
377 /// terminus it asked for, and the link it reads over.
378 ///
379 /// The answer does not depend on the transport, so a document built from a test says what the
380 /// deployment's does.
381 #[cfg(feature = "asyncapi")]
382 fn describe_channel(&self) -> Bindings {
383 bindings::channel(
384 &self.address,
385 self.capability(),
386 self.credit.get(),
387 self.settle,
388 )
389 }
390
391 /// Rejects descriptors that cannot form a subscription, before any I/O.
392 ///
393 /// Only the address is checked here: the credit is a [`NonZeroU32`], so an unusable one
394 /// cannot reach this point.
395 pub(crate) fn validate(&self) -> Result<(), AmqpError> {
396 if self.address.is_empty() {
397 return Err(AmqpError::InvalidAddress(
398 "address must be non-empty".into(),
399 ));
400 }
401 Ok(())
402 }
403}
404
405impl SubscriptionSource<ConnectedAmqpBroker> for AmqpAddress {
406 type Subscriber = AmqpSubscriber;
407
408 /// `AMQP` 1.0 has no delayed redelivery and no wire-level way to declare a node with a
409 /// delivery limit and a dead-letter address, so a spent delivery is this process's to move:
410 /// the runtime publishes the copies and this descriptor says where they land.
411 type Copies = AddressedCopies;
412
413 fn name(&self) -> &str {
414 self.address()
415 }
416
417 async fn subscribe(self, connected: &ConnectedAmqpBroker) -> Result<AmqpSubscriber, AmqpError> {
418 connected.subscribe_address(self).await
419 }
420
421 #[cfg(feature = "asyncapi")]
422 fn channel_bindings(&self) -> Bindings {
423 self.describe_channel()
424 }
425}
426
427impl RedeliveryAddressed<ConnectedAmqpBroker> for AmqpAddress {
428 // The answer is the descriptor's own address, so nothing is asked of the connection and the
429 // call needs no state machine.
430 fn redelivery_address(
431 &self,
432 _connected: &ConnectedAmqpBroker,
433 ) -> impl Future<Output = Result<RedeliveryAddress, AmqpError>> + Send {
434 ready(Ok(self.redelivery_target()))
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn empty_address_is_rejected_before_io() {
444 assert!(matches!(
445 AmqpAddress::queue("").validate(),
446 Err(AmqpError::InvalidAddress(_))
447 ));
448 }
449
450 /// `credit(0)` does not compile: `nonzero!(0)` fails const evaluation, and a runtime zero
451 /// cannot be built either. What is left to pin is that the default is the one that applies.
452 #[test]
453 fn the_credit_defaults_and_takes_an_override() {
454 assert_eq!(AmqpAddress::queue("orders").credit_value(), 256);
455 assert_eq!(
456 AmqpAddress::queue("orders")
457 .credit(nonzero!(64))
458 .credit_value(),
459 64
460 );
461 }
462
463 #[test]
464 fn the_batch_deadline_defaults_and_takes_an_override() {
465 assert_eq!(
466 AmqpAddress::queue("q").batch_wait_value(),
467 DEFAULT_BATCH_WAIT
468 );
469 assert_eq!(
470 AmqpAddress::queue("q")
471 .batch_wait(Duration::from_millis(50))
472 .batch_wait_value(),
473 Duration::from_millis(50)
474 );
475 }
476
477 #[test]
478 fn constructors_pick_the_matching_capability() {
479 assert_eq!(AmqpAddress::queue("q").capability(), Some("queue"));
480 assert_eq!(AmqpAddress::topic("t").capability(), Some("topic"));
481 assert_eq!(AmqpAddress::raw("/queues/q").capability(), None);
482 }
483}