Skip to main content

queuey_rabbitmq/
codec.rs

1//! Pure mapping between [`Envelope`] and AMQP [`BasicProperties`] / headers.
2//!
3//! Nothing here touches the broker, so every function can be unit-tested
4//! without a running RabbitMQ.
5
6use std::time::Duration;
7
8use lapin::{
9    BasicProperties,
10    types::{AMQPValue, FieldTable, LongString, MAX_SHORT_STRING_LENGTH, ShortString},
11};
12use queuey_core::Envelope;
13
14use crate::topology::{
15    HEADER_ATTEMPT, HEADER_ATTEMPTS, HEADER_DEATH_REASON, HEADER_DEFERRALS, HEADER_ORIGINAL_QUEUE,
16    MAX_TTL_MS,
17};
18
19/// `content-type` set on every published message.
20pub const CONTENT_TYPE_JSON: &str = "application/json";
21
22/// `delivery-mode` for a persistent message.
23pub const DELIVERY_MODE_PERSISTENT: u8 = 2;
24
25/// Reason recorded on bodies that could not be decoded as an [`Envelope`].
26pub const REASON_MALFORMED: &str = "malformed envelope";
27
28/// Headers carried by every published envelope.
29///
30/// `x-attempt` and `x-deferrals` mirror [`Envelope::attempt`] and
31/// [`Envelope::deferrals`], so an operator can read both counters in the
32/// management UI without decoding the body. The body stays the source of truth:
33/// nothing in this crate reads these back.
34#[must_use]
35pub fn base_headers(envelope: &Envelope) -> FieldTable {
36    let mut headers = FieldTable::default();
37    headers.insert(HEADER_ATTEMPT.into(), AMQPValue::LongUInt(envelope.attempt));
38    headers.insert(
39        HEADER_DEFERRALS.into(),
40        AMQPValue::LongUInt(envelope.deferrals),
41    );
42    headers
43}
44
45/// AMQP properties for publishing `envelope`.
46///
47/// * `content-type` is `application/json`, matching [`Envelope::to_bytes`].
48/// * `delivery-mode` is `2` (persistent).
49/// * `message-id` is the job id, stable across retries.
50/// * `type` is the job type.
51/// * `priority` is [`Envelope::priority`], always set. Normal work carries `0`;
52///   a deferred envelope carries its queue's top level so it overtakes the
53///   backlog. A queue declared without `x-max-priority` ignores the property,
54///   and a priority above the queue's `x-max-priority` is treated by the broker
55///   as that maximum, so this is safe to set unconditionally.
56/// * `expiration` is set only when `delay` is `Some`, and is the delay in whole
57///   milliseconds rounded up (see [`expiration_ms`]).
58#[must_use]
59pub fn props_for(envelope: &Envelope, delay: Option<Duration>) -> BasicProperties {
60    let props = BasicProperties::default()
61        .with_content_type(CONTENT_TYPE_JSON.into())
62        .with_delivery_mode(DELIVERY_MODE_PERSISTENT)
63        .with_message_id(clamped(&envelope.job_id.to_string()))
64        .with_type(clamped(&envelope.job_type))
65        .with_priority(envelope.priority)
66        .with_headers(base_headers(envelope));
67
68    match delay {
69        Some(delay) => props.with_expiration(clamped(&expiration_ms(delay))),
70        None => props,
71    }
72}
73
74/// AMQP properties for publishing `envelope` into a hold queue.
75///
76/// Identical to [`props_for`] with no delay, and that is the point: a deferred
77/// message must **not** carry an `expiration`. The wait is the hold queue's
78/// queue-wide `x-message-ttl`; a per-message expiration on top of it would
79/// reintroduce exactly the mixed-TTL head-of-line blocking that hold queues
80/// exist to avoid, and a shorter one would release the job early.
81#[must_use]
82pub fn deferred_props(envelope: &Envelope) -> BasicProperties {
83    props_for(envelope, None)
84}
85
86/// Headers recorded on a message routed to `q.dead`.
87///
88/// Extends [`base_headers`] with `x-death-reason`, `x-original-queue` and
89/// `x-attempts`.
90#[must_use]
91pub fn dead_letter_headers(envelope: &Envelope, reason: &str) -> FieldTable {
92    let mut headers = base_headers(envelope);
93    headers.insert(
94        HEADER_DEATH_REASON.into(),
95        AMQPValue::LongString(LongString::from(reason)),
96    );
97    headers.insert(
98        HEADER_ORIGINAL_QUEUE.into(),
99        AMQPValue::LongString(LongString::from(envelope.queue.as_str())),
100    );
101    headers.insert(
102        HEADER_ATTEMPTS.into(),
103        AMQPValue::LongUInt(envelope.attempt),
104    );
105    headers
106}
107
108/// AMQP properties for publishing `envelope` to its dead-letter queue.
109#[must_use]
110pub fn dead_letter_props(envelope: &Envelope, reason: &str) -> BasicProperties {
111    props_for(envelope, None).with_headers(dead_letter_headers(envelope, reason))
112}
113
114/// AMQP properties for a body that could not be decoded as an [`Envelope`].
115///
116/// The original bytes are forwarded verbatim, so there is no attempt counter and
117/// no job metadata to carry, only where it came from and why it was rejected.
118#[must_use]
119pub fn malformed_props(original_queue: &str, reason: &str) -> BasicProperties {
120    let mut headers = FieldTable::default();
121    headers.insert(
122        HEADER_DEATH_REASON.into(),
123        AMQPValue::LongString(LongString::from(reason)),
124    );
125    headers.insert(
126        HEADER_ORIGINAL_QUEUE.into(),
127        AMQPValue::LongString(LongString::from(original_queue)),
128    );
129    BasicProperties::default()
130        .with_delivery_mode(DELIVERY_MODE_PERSISTENT)
131        .with_headers(headers)
132}
133
134/// The AMQP `expiration` string for `delay`: whole milliseconds, rounded up and
135/// clamped to `[1, MAX_TTL_MS]`.
136///
137/// The lower bound exists because RabbitMQ treats an expiration of `0` as
138/// "expire immediately unless a consumer is waiting", which would defeat a
139/// backoff delay.
140///
141/// The upper bound exists because RabbitMQ parses `expiration` as a 32-bit
142/// millisecond count and answers anything larger with `PRECONDITION_FAILED`,
143/// killing the channel. [`MAX_TTL_MS`] is roughly 49 days, far beyond any
144/// sensible retry backoff, so clamping is strictly better than failing.
145#[must_use]
146pub fn expiration_ms(delay: Duration) -> String {
147    let ms = delay
148        .as_nanos()
149        .div_ceil(1_000_000)
150        .clamp(1, u128::from(MAX_TTL_MS));
151    ms.to_string()
152}
153
154/// Convert to a [`ShortString`], truncating at a UTF-8 boundary if needed.
155///
156/// AMQP short strings are capped at 255 bytes and `ShortString::from` panics
157/// past that. Library code must never panic on user-supplied job types, so an
158/// over-long value is truncated rather than rejected.
159fn clamped(value: &str) -> ShortString {
160    ShortString::from(truncate_at_boundary(value, MAX_SHORT_STRING_LENGTH))
161}
162
163/// The longest prefix of `value` that is at most `max` bytes and still valid
164/// UTF-8 (i.e. it never splits a multi-byte character).
165pub(crate) fn truncate_at_boundary(value: &str, max: usize) -> &str {
166    if value.len() <= max {
167        return value;
168    }
169    let mut end = max;
170    while end > 0 && !value.is_char_boundary(end) {
171        end -= 1;
172    }
173    &value[..end]
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    use queuey_core::Envelope;
181    use serde_json::json;
182
183    fn envelope() -> Envelope {
184        Envelope {
185            job_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".parse().unwrap(),
186            job_type: "myapp::jobs::SendEmail".to_owned(),
187            queue: "myapp.emails".to_owned(),
188            attempt: 3,
189            enqueued_at_ms: 1_700_000_000_000,
190            deferrals: 0,
191            priority: 0,
192            payload: json!({ "to": "a@b.c" }),
193        }
194    }
195
196    /// The same envelope after two deferrals onto a 10-level priority queue.
197    fn deferred_envelope() -> Envelope {
198        envelope().deferred(10).deferred(10)
199    }
200
201    #[test]
202    fn props_carry_content_type_and_persistence() {
203        let props = props_for(&envelope(), None);
204        assert_eq!(
205            props.content_type().as_ref().map(ShortString::to_string),
206            Some("application/json".to_owned())
207        );
208        assert_eq!(*props.delivery_mode(), Some(2));
209    }
210
211    #[test]
212    fn props_carry_message_id_and_type() {
213        let props = props_for(&envelope(), None);
214        assert_eq!(
215            props.message_id().as_ref().map(ShortString::to_string),
216            Some("67e55044-10b1-426f-9247-bb680e5fe0c8".to_owned())
217        );
218        assert_eq!(
219            props.kind().as_ref().map(ShortString::to_string),
220            Some("myapp::jobs::SendEmail".to_owned())
221        );
222    }
223
224    #[test]
225    fn props_carry_the_attempt_and_deferrals_headers() {
226        let props = props_for(&envelope(), None);
227        let headers = props.headers().as_ref().expect("headers");
228        assert_eq!(
229            headers.inner().get(HEADER_ATTEMPT),
230            Some(&AMQPValue::LongUInt(3))
231        );
232        assert_eq!(
233            headers.inner().get(HEADER_DEFERRALS),
234            Some(&AMQPValue::LongUInt(0))
235        );
236        assert_eq!(headers.inner().len(), 2);
237    }
238
239    #[test]
240    fn the_deferrals_header_tracks_the_envelope() {
241        let props = props_for(&deferred_envelope(), None);
242        let headers = props.headers().as_ref().expect("headers");
243        assert_eq!(
244            headers.inner().get(HEADER_DEFERRALS),
245            Some(&AMQPValue::LongUInt(2))
246        );
247        // A deferral is not an attempt.
248        assert_eq!(
249            headers.inner().get(HEADER_ATTEMPT),
250            Some(&AMQPValue::LongUInt(3))
251        );
252    }
253
254    #[test]
255    fn props_carry_the_envelope_priority() {
256        // Normal work is priority 0, and the property is always set so a queue
257        // with `x-max-priority` orders every message the same way.
258        assert_eq!(*props_for(&envelope(), None).priority(), Some(0));
259        assert_eq!(*props_for(&deferred_envelope(), None).priority(), Some(10));
260        assert_eq!(
261            *props_for(&envelope(), Some(Duration::from_secs(1))).priority(),
262            Some(0)
263        );
264        assert_eq!(
265            *dead_letter_props(&deferred_envelope(), "boom").priority(),
266            Some(10)
267        );
268    }
269
270    #[test]
271    fn deferred_props_match_an_undelayed_publish() {
272        let envelope = deferred_envelope();
273        assert_eq!(deferred_props(&envelope), props_for(&envelope, None));
274    }
275
276    #[test]
277    fn deferred_props_have_no_expiration_because_the_hold_queue_times_the_wait() {
278        let props = deferred_props(&deferred_envelope());
279        assert!(
280            props.expiration().is_none(),
281            "a per-message expiration would fight the hold queue's x-message-ttl"
282        );
283        assert_eq!(*props.priority(), Some(10));
284        assert_eq!(*props.delivery_mode(), Some(2));
285        let headers = props.headers().as_ref().expect("headers");
286        assert_eq!(
287            headers.inner().get(HEADER_DEFERRALS),
288            Some(&AMQPValue::LongUInt(2))
289        );
290    }
291
292    #[test]
293    fn undelayed_props_have_no_expiration() {
294        assert!(props_for(&envelope(), None).expiration().is_none());
295    }
296
297    #[test]
298    fn delayed_props_carry_expiration_in_millis() {
299        let props = props_for(&envelope(), Some(Duration::from_secs(2)));
300        assert_eq!(
301            props.expiration().as_ref().map(ShortString::to_string),
302            Some("2000".to_owned())
303        );
304    }
305
306    #[test]
307    fn expiration_rounds_sub_millisecond_delays_up() {
308        assert_eq!(expiration_ms(Duration::from_nanos(1)), "1");
309        assert_eq!(expiration_ms(Duration::from_micros(999)), "1");
310    }
311
312    #[test]
313    fn expiration_never_returns_zero() {
314        assert_eq!(expiration_ms(Duration::ZERO), "1");
315    }
316
317    #[test]
318    fn expiration_rounds_partial_millis_up() {
319        assert_eq!(expiration_ms(Duration::from_micros(1_001)), "2");
320        assert_eq!(expiration_ms(Duration::from_micros(1_500)), "2");
321        assert_eq!(expiration_ms(Duration::from_micros(2_000)), "2");
322    }
323
324    #[test]
325    fn expiration_handles_whole_values() {
326        assert_eq!(expiration_ms(Duration::from_millis(1)), "1");
327        assert_eq!(expiration_ms(Duration::from_millis(250)), "250");
328        assert_eq!(expiration_ms(Duration::from_secs(300)), "300000");
329    }
330
331    #[test]
332    fn expiration_is_clamped_to_what_rabbitmq_accepts() {
333        // Not `u64::MAX`: RabbitMQ parses `expiration` as 32-bit millis and
334        // answers anything larger with PRECONDITION_FAILED.
335        assert_eq!(expiration_ms(Duration::MAX), MAX_TTL_MS.to_string());
336    }
337
338    #[test]
339    fn expiration_exactly_at_the_limit_is_kept_verbatim() {
340        assert_eq!(
341            expiration_ms(Duration::from_millis(u64::from(MAX_TTL_MS))),
342            MAX_TTL_MS.to_string()
343        );
344    }
345
346    #[test]
347    fn expiration_one_millisecond_past_the_limit_is_clamped() {
348        assert_eq!(
349            expiration_ms(Duration::from_millis(u64::from(MAX_TTL_MS) + 1)),
350            MAX_TTL_MS.to_string()
351        );
352    }
353
354    #[test]
355    fn a_clamped_expiration_still_fits_a_short_string() {
356        let props = props_for(&envelope(), Some(Duration::MAX));
357        let expiration = props.expiration().as_ref().expect("expiration").to_string();
358        assert!(expiration.len() <= MAX_SHORT_STRING_LENGTH);
359        assert_eq!(expiration, "4294967295");
360    }
361
362    #[test]
363    fn dead_letter_headers_record_reason_queue_and_attempts() {
364        let headers = dead_letter_headers(&envelope(), "handler returned Fatal");
365        assert_eq!(
366            headers.inner().get(HEADER_DEATH_REASON),
367            Some(&AMQPValue::LongString(LongString::from(
368                "handler returned Fatal"
369            )))
370        );
371        assert_eq!(
372            headers.inner().get(HEADER_ORIGINAL_QUEUE),
373            Some(&AMQPValue::LongString(LongString::from("myapp.emails")))
374        );
375        assert_eq!(
376            headers.inner().get(HEADER_ATTEMPTS),
377            Some(&AMQPValue::LongUInt(3))
378        );
379        assert_eq!(
380            headers.inner().get(HEADER_ATTEMPT),
381            Some(&AMQPValue::LongUInt(3))
382        );
383    }
384
385    #[test]
386    fn dead_letter_props_keep_identity_and_drop_expiration() {
387        let props = dead_letter_props(&envelope(), "boom");
388        assert_eq!(
389            props.message_id().as_ref().map(ShortString::to_string),
390            Some("67e55044-10b1-426f-9247-bb680e5fe0c8".to_owned())
391        );
392        assert!(props.expiration().is_none());
393        let headers = props.headers().as_ref().expect("headers");
394        assert!(headers.contains_key(HEADER_DEATH_REASON));
395    }
396
397    #[test]
398    fn malformed_props_record_origin_and_reason_only() {
399        let props = malformed_props("myapp.emails", REASON_MALFORMED);
400        assert_eq!(*props.delivery_mode(), Some(2));
401        assert!(props.message_id().is_none());
402        let headers = props.headers().as_ref().expect("headers");
403        assert_eq!(
404            headers.inner().get(HEADER_DEATH_REASON),
405            Some(&AMQPValue::LongString(LongString::from(
406                "malformed envelope"
407            )))
408        );
409        assert_eq!(
410            headers.inner().get(HEADER_ORIGINAL_QUEUE),
411            Some(&AMQPValue::LongString(LongString::from("myapp.emails")))
412        );
413        assert!(!headers.contains_key(HEADER_ATTEMPT));
414    }
415
416    #[test]
417    fn over_long_job_type_is_truncated_not_panicked() {
418        let mut env = envelope();
419        env.job_type = "é".repeat(400);
420        let props = props_for(&env, None);
421        let kind = props.kind().as_ref().expect("type").to_string();
422        assert!(
423            kind.len() <= MAX_SHORT_STRING_LENGTH,
424            "len was {}",
425            kind.len()
426        );
427        // 'é' is two bytes, so truncation must land on an even byte offset.
428        assert_eq!(kind.len(), 254);
429        assert!(kind.chars().all(|c| c == 'é'));
430    }
431
432    #[test]
433    fn truncation_never_splits_a_character() {
434        assert_eq!(truncate_at_boundary("héllo", 2), "h");
435        assert_eq!(truncate_at_boundary("héllo", 3), "hé");
436        assert_eq!(truncate_at_boundary("héllo", 99), "héllo");
437        assert_eq!(truncate_at_boundary("é", 1), "");
438    }
439
440    #[test]
441    fn exactly_max_length_is_kept_verbatim() {
442        let mut env = envelope();
443        env.job_type = "a".repeat(MAX_SHORT_STRING_LENGTH);
444        let props = props_for(&env, None);
445        assert_eq!(
446            props.kind().as_ref().map(ShortString::to_string),
447            Some(env.job_type)
448        );
449    }
450}