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