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}