1use 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
19pub const CONTENT_TYPE_JSON: &str = "application/json";
21
22pub const DELIVERY_MODE_PERSISTENT: u8 = 2;
24
25pub const REASON_MALFORMED: &str = "malformed envelope";
27
28#[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#[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#[must_use]
82pub fn deferred_props(envelope: &Envelope) -> BasicProperties {
83 props_for(envelope, None)
84}
85
86#[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#[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#[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#[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
154fn clamped(value: &str) -> ShortString {
160 ShortString::from(truncate_at_boundary(value, MAX_SHORT_STRING_LENGTH))
161}
162
163pub(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 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 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 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 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 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}