Skip to main content

ruststream_lapin/queue/
quorum.rs

1//! The quorum queue descriptor.
2
3use std::num::NonZeroU16;
4use std::time::Duration;
5
6use lapin::types::{AMQPValue, FieldTable};
7#[cfg(feature = "asyncapi")]
8use ruststream::asyncapi::Bindings;
9use ruststream::runtime::IntoSource;
10use ruststream::{BrokerMoves, RetryDeclaration, SubscriptionSource};
11
12use super::spec::{
13    Binding, DEAD_LETTER_EXCHANGE, DEAD_LETTER_ROUTING_KEY, DELIVERY_LIMIT, QueueKind, QueueSpec,
14    queue_settings,
15};
16#[cfg(feature = "asyncapi")]
17use crate::bindings::{consumer_operation, queue_channel};
18use crate::broker::ConnectedLapinBroker;
19use crate::delay::Delay;
20use crate::error::AmqpError;
21use crate::exchange::RabbitExchange;
22use crate::subscriber::LapinSubscriber;
23
24/// Describes one quorum-queue subscription: the queue, its expected settings, and its bindings.
25///
26/// A quorum queue counts the deliveries a message has spent and ends a poison loop itself: with
27/// an `x-delivery-limit` and a dead-letter route it carries a message away once its deliveries
28/// run out, whether or not a service is running to count them. So the retries of a registration
29/// mounted here are the broker's, and `out_retry(policy)` does not compile on it: the server moves
30/// the spent message, and the service publishes no copy. A registration declares the pair at the mount site:
31///
32/// ```
33/// use ruststream_lapin::prelude::*;
34/// use serde::Deserialize;
35///
36/// #[derive(Deserialize)]
37/// struct Payout {
38///     id: u64,
39/// }
40///
41/// #[subscriber(RabbitQuorumQueue::new("payouts"))]
42/// async fn on_payout(payout: &Payout) -> HandlerOutcome {
43///     tracing::warn!(payout.id, "the bank refused, trying again");
44///     HandlerOutcome::retry()
45/// }
46///
47/// fn app() -> RustStream {
48///     let broker = LapinBroker::new("amqp://localhost:5672").declare_topology(true);
49///     RustStream::new(AppInfo::new("payouts", "0.1.0")).with_broker(broker, |b| {
50///         // The server applies this cap: it becomes the queue's delivery limit and dead-letter
51///         // route.
52///         b.include(on_payout)
53///             .max_attempts(nonzero!(5u32))
54///             .dead_letter("payouts.dead");
55///     })
56/// }
57/// ```
58///
59/// The pair becomes this queue's `x-delivery-limit` and dead-letter route when the broker opts
60/// into [`declare_topology(true)`](crate::LapinBroker::declare_topology). A queue declared
61/// elsewhere carries its own arguments, so subscribing then refuses the declaration rather than
62/// promising a cap nothing applies; configure the queue on the broker and mount it without the
63/// pair. The crate reads the queue's count either way.
64///
65/// A quorum queue is durable, shared and permanent by definition, so those three settings are not
66/// here to be set wrong. For the single-node implementation take [`RabbitQueue`](crate::RabbitQueue).
67///
68/// # Examples
69///
70/// ```
71/// use ruststream_lapin::prelude::*;
72/// use serde::Deserialize;
73///
74/// #[derive(Deserialize)]
75/// struct Charge {
76///     id: u64,
77/// }
78///
79/// // A charge the payment provider keeps refusing is moved to `dead-letters` by the server after
80/// // its fifth delivery: the limit counts the four returns it survives.
81/// #[subscriber(RabbitQuorumQueue::new("charges")
82///     .bind(RabbitExchange::topic("events"), "charge.*")
83///     .dead_letter_exchange("dead-letters")
84///     .delivery_limit(4))]
85/// async fn take_payment(charge: &Charge) -> HandlerOutcome {
86///     tracing::info!(charge.id, "charging");
87///     HandlerOutcome::retry()
88/// }
89///
90/// fn app() -> RustStream {
91///     let broker = LapinBroker::new("amqp://localhost:5672").declare_topology(true);
92///     RustStream::new(AppInfo::new("payments", "0.1.0")).with_broker(broker, |b| {
93///         b.include(take_payment);
94///     })
95/// }
96/// ```
97#[derive(Debug, Clone, PartialEq)]
98pub struct RabbitQuorumQueue {
99    spec: QueueSpec,
100    retry: RetryDeclaration,
101}
102
103impl RabbitQuorumQueue {
104    /// Describes the quorum queue `name`.
105    #[must_use]
106    pub fn new(name: impl Into<String>) -> Self {
107        Self {
108            spec: QueueSpec::new(QueueKind::Quorum, name.into()),
109            retry: RetryDeclaration::new(),
110        }
111    }
112
113    /// How often the queue returns a message before it carries it away (`x-delivery-limit`).
114    ///
115    /// The argument counts the returns a message survives, so it is one less than the number of
116    /// deliveries the queue allows: `delivery_limit(2)` hands a message out three times. Where it
117    /// goes then is [`dead_letter_exchange`](Self::dead_letter_exchange); a limit with no
118    /// dead-letter route drops the spent message instead.
119    ///
120    /// This is the queue's own policy, for a queue whose retries are not one registration's
121    /// business. A mount site that declares `max_attempts(n).dead_letter(..)` is the more specific
122    /// statement and is written over this.
123    #[must_use]
124    pub fn delivery_limit(mut self, returns: u32) -> Self {
125        self.spec
126            .set(DELIVERY_LIMIT, AMQPValue::LongLongInt(i64::from(returns)));
127        self
128    }
129
130    pub(crate) const fn spec(&self) -> &QueueSpec {
131        &self.spec
132    }
133
134    /// What the mount site declared about this registration's retries, applied when the queue is
135    /// declared.
136    pub(crate) const fn retry(&self) -> &RetryDeclaration {
137        &self.retry
138    }
139
140    /// Refuses a declaration the queue cannot carry, before the subscription opens.
141    ///
142    /// A native dead-letter policy needs both halves, and both halves need a queue this service
143    /// declares - a queue declared elsewhere carries the arguments whoever declared it gave it.
144    /// Neither is knowable before the broker is configured, which is why this is a startup error
145    /// and not a compile error.
146    pub(crate) fn check_retry(&self, declares_topology: bool) -> Result<(), AmqpError> {
147        match (self.retry.max_attempts(), self.retry.dead_letter()) {
148            // Both halves, on a queue this service declares: the declaration becomes arguments.
149            (Some(_), Some(_)) if declares_topology => Ok(()),
150            // Nothing declared: the queue keeps whatever policy it was given.
151            (None, None) => Ok(()),
152            (Some(_), None) => Err(AmqpError::InvalidOptions(format!(
153                "quorum queue {:?} was mounted with `max_attempts(..)` and no `dead_letter(..)`: \
154                 the queue carries a spent delivery away only when it knows where to, and a \
155                 delivery limit on its own drops it. Declare both at the mount site, or neither \
156                 and let the queue keep its own policy",
157                self.name(),
158            ))),
159            (None, Some(_)) => Err(AmqpError::InvalidOptions(format!(
160                "quorum queue {:?} was mounted with `dead_letter(..)` and no `max_attempts(..)`: \
161                 nothing tells the queue when a delivery is spent, so the destination is never \
162                 reached. Declare both at the mount site, or neither and let the queue keep its \
163                 own policy",
164                self.name(),
165            ))),
166            (Some(_), Some(_)) => Err(AmqpError::InvalidOptions(format!(
167                "quorum queue {:?} applies `max_attempts(..).dead_letter(..)` as its own \
168                 arguments, and this service does not declare it. Enable \
169                 `declare_topology(true)`, or configure `x-delivery-limit` and \
170                 `x-dead-letter-exchange` on the queue itself and mount the handler without the \
171                 pair",
172                self.name(),
173            ))),
174        }
175    }
176}
177
178queue_settings!(RabbitQuorumQueue);
179
180/// The descriptor is its own source, so the manual path's `subscriber(source, body)` takes a
181/// `RabbitQuorumQueue` where the attribute path writes it in the decorator.
182impl IntoSource for RabbitQuorumQueue {
183    type Source = Self;
184
185    fn into_source(self) -> Self {
186        self
187    }
188}
189
190impl SubscriptionSource<ConnectedLapinBroker> for RabbitQuorumQueue {
191    type Subscriber = LapinSubscriber;
192
193    /// The queue moves a spent delivery itself: its `x-delivery-limit` counts the deliveries and
194    /// its dead-letter route takes the message that runs out of them, with nothing published by
195    /// this process. That is what makes `out_retry(policy)` a compile error here, and what keeps
196    /// the cap holding while the service is down.
197    type Copies = BrokerMoves;
198
199    fn name(&self) -> &str {
200        &self.spec.name
201    }
202
203    async fn subscribe(
204        self,
205        connected: &ConnectedLapinBroker,
206    ) -> Result<Self::Subscriber, AmqpError> {
207        connected.subscribe(self).await
208    }
209
210    /// Takes the registration's cap and dead-letter destination into the descriptor, so the queue
211    /// this broker declares carries them as topology.
212    fn declare_retry(mut self, declaration: &RetryDeclaration) -> Self {
213        self.retry = declaration.clone();
214        self
215    }
216
217    /// The queue this subscription consumes, as the `amqp` binding names its settings.
218    #[cfg(feature = "asyncapi")]
219    fn channel_bindings(&self) -> Bindings {
220        queue_channel(&self.spec)
221    }
222
223    /// The consumer's own half: this crate acknowledges by hand on every subscription.
224    #[cfg(feature = "asyncapi")]
225    fn operation_bindings(&self) -> Bindings {
226        consumer_operation()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use lapin::types::{AMQPValue, ShortString};
233    use ruststream::{RetryDeclaration, SubscriptionSource, nonzero};
234
235    use super::RabbitQuorumQueue;
236    use crate::broker::ConnectedLapinBroker;
237    use crate::queue::spec::declared_arguments;
238
239    // What the mount site declared reaches the queue as topology: a delivery limit one short of
240    // the cap, and a dead-letter route to the destination on the default exchange.
241    #[test]
242    fn a_declaration_becomes_the_quorum_queues_arguments() {
243        let declaration = RetryDeclaration::new()
244            .with_max_attempts(nonzero!(3u32))
245            .with_dead_letter("orders.dead");
246        let queue = <RabbitQuorumQueue as SubscriptionSource<ConnectedLapinBroker>>::declare_retry(
247            RabbitQuorumQueue::new("orders.quorum"),
248            &declaration,
249        );
250
251        let arguments = declared_arguments(queue.spec(), Some(queue.retry()));
252
253        let argument = |name: &str| arguments.inner().get(&ShortString::from(name)).cloned();
254        assert_eq!(
255            argument("x-queue-type"),
256            Some(AMQPValue::LongString("quorum".into()))
257        );
258        assert_eq!(
259            argument("x-delivery-limit"),
260            Some(AMQPValue::LongLongInt(2))
261        );
262        assert_eq!(
263            argument("x-dead-letter-exchange"),
264            Some(AMQPValue::LongString(String::new().into()))
265        );
266        assert_eq!(
267            argument("x-dead-letter-routing-key"),
268            Some(AMQPValue::LongString("orders.dead".into()))
269        );
270    }
271}