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}