queuey_rabbitmq/lib.rs
1//! RabbitMQ backend for [`queuey`], built on [`lapin`].
2//!
3//! [`RabbitMqBackend`] implements [`queuey_core::Backend`]: it owns one
4//! AMQP connection, publishes with publisher confirms, and hands the worker
5//! runtime a stream of [`RabbitMqDelivery`] values.
6//!
7//! ```no_run
8//! use std::{sync::Arc, time::Duration};
9//!
10//! use queuey_core::{Backend, QueueConfig};
11//! use queuey_rabbitmq::{RabbitMqBackend, RabbitMqOptions};
12//!
13//! # async fn example() -> queuey_core::Result<()> {
14//! let backend = RabbitMqBackend::with_options(
15//! "amqp://guest:guest@localhost:5672/%2f",
16//! RabbitMqOptions::default().retry_granularity(Duration::from_secs(5)),
17//! )
18//! .await?;
19//!
20//! let emails = QueueConfig::new("myapp.emails").prefetch(10);
21//! backend.declare(std::slice::from_ref(&emails)).await?;
22//!
23//! let backend = Arc::new(backend);
24//! // ... hand `backend` to a `Producer` / `Worker` ...
25//! backend.close().await?;
26//! # Ok(()) }
27//! ```
28//!
29//! # Topology
30//!
31//! Each logical queue `q` is backed by two long-lived broker queues, `q` and
32//! `q.dead`, plus a short-lived *hold* queue `q.deferred.{ttl_ms}` per distinct
33//! delay. Every wait, whether a retry backoff, a delayed enqueue or a deferral,
34//! happens in a hold queue. See [`topology`] for the exact arguments.
35//!
36//! # Why hold queues, and not one wait queue with per-message expirations
37//!
38//! RabbitMQ only expires the message at the *head* of a classic queue. In a
39//! shared wait queue, a message with a five-minute `expiration` at the head
40//! holds back every one-second `expiration` queued behind it, and exponential
41//! backoff produces exactly that mix of delays. So instead the delay is part of
42//! the queue *name*, the wait is the queue-wide `x-message-ttl`, and every
43//! message in `q.deferred.30000` expires in publish order. A short wait is never
44//! stuck behind a long one, because the two live in different queues.
45//!
46//! Delays are rounded **up** to a granularity to bound how many hold queues
47//! exist at once: [`RabbitMqOptions::retry_granularity`] for retries and
48//! [`Producer::enqueue_after`](queuey_core::Producer::enqueue_after),
49//! [`RabbitMqOptions::deferred_granularity`] for deferrals, both `1s` by
50//! default. The hold queue is declared on demand right before each publish:
51//! an idle hold queue deletes itself one TTL after the last publish to it
52//! (`x-expires = 2 * TTL`), and every declare resets that timer.
53//!
54//! # Retry versus deferral
55//!
56//! Both wait in the same hold queues. They differ in what happens when the job
57//! is back on `q`:
58//!
59//! * A **retry** ([`Delivery::retry`](queuey_core::Delivery::retry), and a
60//! delayed [`Backend::publish`](queuey_core::Backend::publish)) carries
61//! priority `0` and joins the back of the queue like any other message. Its
62//! attempt counter has been incremented.
63//! * A **deferral** ([`Backend::defer`](queuey_core::Backend::defer),
64//! [`Delivery::defer`](queuey_core::Delivery::defer)) is what a
65//! `429 Too Many Requests` with `Retry-After: 30` needs: the job did not fail,
66//! must not burn an attempt, and must run **ahead of the backlog** when it
67//! returns. `q` is declared with `x-max-priority` from
68//! [`QueueConfig::max_priority`](queuey_core::QueueConfig::max_priority)
69//! (default `Some(10)`), every publish carries the envelope's `priority`, and a
70//! deferred envelope carries the queue's top level, so it is served before
71//! everything that piled up meanwhile. "Ahead of the backlog" means ahead of
72//! what is still *on* the queue: a consumer with prefetch `N` already holds up
73//! to `N` backlog messages, and the returning deferral is first among what is
74//! left.
75//!
76//! ## What a hold requires
77//!
78//! * **The queue must have been declared through this backend, in this
79//! process.** Otherwise the hold queue's durability and the queue it
80//! dead-letters back to would be guesses, and a TTL expiry into a queue that
81//! does not exist is discarded silently by the broker. Unlike a
82//! `mandatory` publish, nothing comes back and nothing is logged. Holding
83//! onto an unknown queue is
84//! [`Error::UnknownQueue`](queuey_core::Error::UnknownQueue)
85//! instead. `Producer::new` and `WorkerBuilder::build` declare the queue set;
86//! `Producer::new_undeclared` deliberately does not, so a producer built that
87//! way can enqueue, but not enqueue with a delay or defer.
88//! * **The delay must fit.** It is capped at
89//! [`MAX_DEFERRAL_MS`](topology::MAX_DEFERRAL_MS), about 24.8 days. That is half of
90//! what a 32-bit millisecond TTL can express, because the hold queue's
91//! `x-expires` is twice its TTL. A longer delay is refused rather than
92//! clamped: releasing a job early is the one thing a hold promises not to
93//! do. Rounding up to the granularity happens first, so a delay just under the
94//! cap can be refused too.
95//!
96//! Both failures happen *before* anything is acked, so from
97//! [`Delivery::retry`](queuey_core::Delivery::retry) and
98//! [`Delivery::defer`](queuey_core::Delivery::defer) they leave the
99//! original message unacknowledged and the broker redelivers it.
100//!
101//! ## Upgrading from the `q.retry` wait queue
102//!
103//! Earlier versions declared a `q.retry` queue per work queue and published
104//! retries into it with a per-message `expiration`. This version neither
105//! declares nor uses it. Nothing needs migrating: messages still waiting in an
106//! existing `q.retry` expire back onto `q` on their own, because the
107//! dead-letter routing is an argument of that queue, and workers running the
108//! old version keep declaring it themselves. Delete `q.retry` once it is empty
109//! and no old worker is left. `RabbitMqOptions::retry_suffix` is gone with it;
110//! [`RabbitMqOptions::retry_granularity`] is the retry tunable now.
111//!
112//! ## Breaking topology change
113//!
114//! `x-max-priority` is a *declaration* argument, and RabbitMQ refuses to change
115//! the arguments of a queue that already exists: the declaration is answered
116//! with `PRECONDITION_FAILED`, which closes the channel and surfaces here as an
117//! error from [`declare`](queuey_core::Backend::declare).
118//!
119//! A `q` created before this feature has no `x-max-priority`, so **declaring it
120//! again with the default config will fail**. Either:
121//!
122//! * drain and delete `q`, then let this backend redeclare it. Deferred jobs
123//! then come back ahead of the backlog; or
124//! * set `max_priority = 0` on the queue's
125//! [`QueueConfig`](queuey_core::QueueConfig) (or
126//! `#[queue(max_priority = 0)]`), which declares `q` exactly as before.
127//! Deferral still works, it just returns jobs FIFO instead of ahead of the
128//! queue.
129//!
130//! `q.dead` and hold queues are unchanged, so only `q` is affected.
131//!
132//! # Guarantees
133//!
134//! * Every publish (enqueue, retry, defer, dead-letter) is `mandatory` and
135//! confirmed by the broker before it is reported as successful. A routing key
136//! that matches no queue is returned by the broker and reported as an error
137//! rather than passing for a confirmed publish.
138//! * [`Delivery::retry`](queuey_core::Delivery::retry),
139//! [`Delivery::defer`](queuey_core::Delivery::defer) and
140//! [`Delivery::dead_letter`](queuey_core::Delivery::dead_letter)
141//! publish first and ack second, and skip the ack entirely when the publish
142//! fails, so a job is never lost. At worst it is redelivered. Different
143//! delays never block each other: each waits in its own hold queue. With
144//! [`RabbitMqOptions::declare_dead_letter_queues`] off, `dead_letter` rejects
145//! the delivery instead of publishing to a `q.dead` this backend does not own.
146//! * Messages whose body is not a valid [`queuey_core::Envelope`] are
147//! moved aside and logged, never surfaced as a stream error, so one poison
148//! message cannot stall a consumer.
149//!
150//! # Reconnection
151//!
152//! The connection is a slot, not a socket. When it drops, the first operation to
153//! notice dials a replacement while the rest queue behind that one attempt,
154//! every queue this backend declared is re-declared on the new connection, and
155//! the consumer streams resubscribe and keep yielding. A publish issued during
156//! the outage waits rather than failing, and
157//! [`Worker::run`](queuey_core::Worker::run) keeps running across a broker
158//! restart.
159//!
160//! Pacing is [`RabbitMqOptions::reconnect`]'s job, and it takes any
161//! [`ReconnectPolicy`]. The default, [`BackoffPolicy`], is unlimited, so a
162//! broker that never returns is a stalled worker and a stream of `WARN` logs
163//! rather than an error; bound it with [`BackoffPolicy::max_attempts`], swap in
164//! a policy of your own when a backoff curve is not the right shape (a circuit
165//! breaker, a schedule, a different answer for an authentication failure than
166//! for a refused connection), or pass [`None`] for the original fail-fast
167//! behaviour, where consumer streams end and
168//! [`Error::ConsumerStopped`](queuey_core::Error::ConsumerStopped) surfaces.
169//!
170//! ```
171//! use std::time::Duration;
172//!
173//! use queuey_rabbitmq::{Attempt, RabbitMqOptions, Rebuilding, ReconnectPolicy};
174//!
175//! /// Retries the connection forever, but gives up on a consumer whose queue
176//! /// has gone missing: waiting does not bring a deleted queue back.
177//! #[derive(Debug)]
178//! struct ConnectionOnly;
179//!
180//! impl ReconnectPolicy for ConnectionOnly {
181//! fn next_delay(&self, attempt: Attempt<'_>) -> Option<Duration> {
182//! match attempt.rebuilding {
183//! Rebuilding::Consumer if attempt.failures >= 3 => None,
184//! _ => Some(Duration::from_secs(2)),
185//! }
186//! }
187//! }
188//!
189//! let options = RabbitMqOptions::default().reconnect_with(ConnectionOnly);
190//! ```
191//!
192//! Two things do not survive an outage. Jobs that were in flight are requeued by
193//! the broker and delivered again, and their first run's settle fails (counted
194//! in
195//! [`WorkerHandle::settle_failures`](queuey_core::WorkerHandle::settle_failures));
196//! that is the at-least-once contract above, not a new one. And the *first*
197//! connection is not retried at all: [`RabbitMqBackend::connect`] fails if the
198//! broker is unreachable at startup, rather than blocking its caller in a
199//! backoff loop.
200//!
201//! [`queuey`]: queuey_core
202
203#![forbid(unsafe_code)]
204#![warn(missing_docs)]
205
206mod backend;
207mod connection;
208mod delivery;
209mod error;
210mod options;
211mod publisher;
212mod reconnect;
213
214pub mod codec;
215pub mod topology;
216
217pub use backend::RabbitMqBackend;
218pub use delivery::RabbitMqDelivery;
219pub use options::RabbitMqOptions;
220pub use reconnect::{Attempt, BackoffPolicy, Rebuilding, ReconnectPolicy};
221
222/// Re-export of the backoff curve [`BackoffPolicy`] is built from, so a policy
223/// can be tuned without naming `queuey-core` as a dependency.
224pub use queuey_core::Backoff;
225
226/// Re-export of the `lapin` version this backend is built against, so callers
227/// can name [`lapin::ConnectionProperties`] without pinning it themselves.
228pub use lapin;