1use lapin::{
7 BasicProperties,
8 types::{AMQPValue, FieldTable, LongString, MAX_SHORT_STRING_LENGTH, ShortString},
9};
10use queuey_core::Envelope;
11
12use crate::topology::{
13 HEADER_ATTEMPT, HEADER_ATTEMPTS, HEADER_DEATH_REASON, HEADER_DEFERRALS, HEADER_ORIGINAL_QUEUE,
14};
15
16pub const CONTENT_TYPE_JSON: &str = "application/json";
18
19pub const DELIVERY_MODE_PERSISTENT: u8 = 2;
21
22pub const REASON_MALFORMED: &str = "malformed envelope";
24
25#[must_use]
32pub fn base_headers(envelope: &Envelope) -> FieldTable {
33 let mut headers = FieldTable::default();
34 headers.insert(HEADER_ATTEMPT.into(), AMQPValue::LongUInt(envelope.attempt));
35 headers.insert(
36 HEADER_DEFERRALS.into(),
37 AMQPValue::LongUInt(envelope.deferrals),
38 );
39 headers
40}
41
42#[must_use]
59pub fn props_for(envelope: &Envelope) -> BasicProperties {
60 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
69#[must_use]
74pub fn dead_letter_headers(envelope: &Envelope, reason: &str) -> FieldTable {
75 let mut headers = base_headers(envelope);
76 headers.insert(
77 HEADER_DEATH_REASON.into(),
78 AMQPValue::LongString(LongString::from(reason)),
79 );
80 headers.insert(
81 HEADER_ORIGINAL_QUEUE.into(),
82 AMQPValue::LongString(LongString::from(envelope.queue.as_str())),
83 );
84 headers.insert(
85 HEADER_ATTEMPTS.into(),
86 AMQPValue::LongUInt(envelope.attempt),
87 );
88 headers
89}
90
91#[must_use]
93pub fn dead_letter_props(envelope: &Envelope, reason: &str) -> BasicProperties {
94 props_for(envelope).with_headers(dead_letter_headers(envelope, reason))
95}
96
97#[must_use]
102pub fn malformed_props(original_queue: &str, reason: &str) -> BasicProperties {
103 let mut headers = FieldTable::default();
104 headers.insert(
105 HEADER_DEATH_REASON.into(),
106 AMQPValue::LongString(LongString::from(reason)),
107 );
108 headers.insert(
109 HEADER_ORIGINAL_QUEUE.into(),
110 AMQPValue::LongString(LongString::from(original_queue)),
111 );
112 BasicProperties::default()
113 .with_delivery_mode(DELIVERY_MODE_PERSISTENT)
114 .with_headers(headers)
115}
116
117fn clamped(value: &str) -> ShortString {
123 ShortString::from(truncate_at_boundary(value, MAX_SHORT_STRING_LENGTH))
124}
125
126pub(crate) fn truncate_at_boundary(value: &str, max: usize) -> &str {
129 if value.len() <= max {
130 return value;
131 }
132 let mut end = max;
133 while end > 0 && !value.is_char_boundary(end) {
134 end -= 1;
135 }
136 &value[..end]
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 use queuey_core::Envelope;
144 use serde_json::json;
145
146 fn envelope() -> Envelope {
147 Envelope {
148 job_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".parse().unwrap(),
149 job_type: "myapp::jobs::SendEmail".to_owned(),
150 queue: "myapp.emails".to_owned(),
151 attempt: 3,
152 enqueued_at_ms: 1_700_000_000_000,
153 deferrals: 0,
154 priority: 0,
155 payload: json!({ "to": "a@b.c" }),
156 }
157 }
158
159 fn deferred_envelope() -> Envelope {
161 envelope().deferred(10).deferred(10)
162 }
163
164 #[test]
165 fn props_carry_content_type_and_persistence() {
166 let props = props_for(&envelope());
167 assert_eq!(
168 props.content_type().as_ref().map(ShortString::to_string),
169 Some("application/json".to_owned())
170 );
171 assert_eq!(*props.delivery_mode(), Some(2));
172 }
173
174 #[test]
175 fn props_carry_message_id_and_type() {
176 let props = props_for(&envelope());
177 assert_eq!(
178 props.message_id().as_ref().map(ShortString::to_string),
179 Some("67e55044-10b1-426f-9247-bb680e5fe0c8".to_owned())
180 );
181 assert_eq!(
182 props.kind().as_ref().map(ShortString::to_string),
183 Some("myapp::jobs::SendEmail".to_owned())
184 );
185 }
186
187 #[test]
188 fn props_carry_the_attempt_and_deferrals_headers() {
189 let props = props_for(&envelope());
190 let headers = props.headers().as_ref().expect("headers");
191 assert_eq!(
192 headers.inner().get(HEADER_ATTEMPT),
193 Some(&AMQPValue::LongUInt(3))
194 );
195 assert_eq!(
196 headers.inner().get(HEADER_DEFERRALS),
197 Some(&AMQPValue::LongUInt(0))
198 );
199 assert_eq!(headers.inner().len(), 2);
200 }
201
202 #[test]
203 fn the_deferrals_header_tracks_the_envelope() {
204 let props = props_for(&deferred_envelope());
205 let headers = props.headers().as_ref().expect("headers");
206 assert_eq!(
207 headers.inner().get(HEADER_DEFERRALS),
208 Some(&AMQPValue::LongUInt(2))
209 );
210 assert_eq!(
212 headers.inner().get(HEADER_ATTEMPT),
213 Some(&AMQPValue::LongUInt(3))
214 );
215 }
216
217 #[test]
218 fn props_carry_the_envelope_priority() {
219 assert_eq!(*props_for(&envelope()).priority(), Some(0));
222 assert_eq!(*props_for(&deferred_envelope()).priority(), Some(10));
223 assert_eq!(
224 *dead_letter_props(&deferred_envelope(), "boom").priority(),
225 Some(10)
226 );
227 }
228
229 #[test]
230 fn props_never_carry_an_expiration_because_the_hold_queue_times_the_wait() {
231 assert!(props_for(&envelope()).expiration().is_none());
234 assert!(props_for(&deferred_envelope()).expiration().is_none());
235 }
236
237 #[test]
238 fn dead_letter_headers_record_reason_queue_and_attempts() {
239 let headers = dead_letter_headers(&envelope(), "handler returned Fatal");
240 assert_eq!(
241 headers.inner().get(HEADER_DEATH_REASON),
242 Some(&AMQPValue::LongString(LongString::from(
243 "handler returned Fatal"
244 )))
245 );
246 assert_eq!(
247 headers.inner().get(HEADER_ORIGINAL_QUEUE),
248 Some(&AMQPValue::LongString(LongString::from("myapp.emails")))
249 );
250 assert_eq!(
251 headers.inner().get(HEADER_ATTEMPTS),
252 Some(&AMQPValue::LongUInt(3))
253 );
254 assert_eq!(
255 headers.inner().get(HEADER_ATTEMPT),
256 Some(&AMQPValue::LongUInt(3))
257 );
258 }
259
260 #[test]
261 fn dead_letter_props_keep_identity_and_drop_expiration() {
262 let props = dead_letter_props(&envelope(), "boom");
263 assert_eq!(
264 props.message_id().as_ref().map(ShortString::to_string),
265 Some("67e55044-10b1-426f-9247-bb680e5fe0c8".to_owned())
266 );
267 assert!(props.expiration().is_none());
268 let headers = props.headers().as_ref().expect("headers");
269 assert!(headers.contains_key(HEADER_DEATH_REASON));
270 }
271
272 #[test]
273 fn malformed_props_record_origin_and_reason_only() {
274 let props = malformed_props("myapp.emails", REASON_MALFORMED);
275 assert_eq!(*props.delivery_mode(), Some(2));
276 assert!(props.message_id().is_none());
277 let headers = props.headers().as_ref().expect("headers");
278 assert_eq!(
279 headers.inner().get(HEADER_DEATH_REASON),
280 Some(&AMQPValue::LongString(LongString::from(
281 "malformed envelope"
282 )))
283 );
284 assert_eq!(
285 headers.inner().get(HEADER_ORIGINAL_QUEUE),
286 Some(&AMQPValue::LongString(LongString::from("myapp.emails")))
287 );
288 assert!(!headers.contains_key(HEADER_ATTEMPT));
289 }
290
291 #[test]
292 fn over_long_job_type_is_truncated_not_panicked() {
293 let mut env = envelope();
294 env.job_type = "é".repeat(400);
295 let props = props_for(&env);
296 let kind = props.kind().as_ref().expect("type").to_string();
297 assert!(
298 kind.len() <= MAX_SHORT_STRING_LENGTH,
299 "len was {}",
300 kind.len()
301 );
302 assert_eq!(kind.len(), 254);
304 assert!(kind.chars().all(|c| c == 'é'));
305 }
306
307 #[test]
308 fn truncation_never_splits_a_character() {
309 assert_eq!(truncate_at_boundary("héllo", 2), "h");
310 assert_eq!(truncate_at_boundary("héllo", 3), "hé");
311 assert_eq!(truncate_at_boundary("héllo", 99), "héllo");
312 assert_eq!(truncate_at_boundary("é", 1), "");
313 }
314
315 #[test]
316 fn exactly_max_length_is_kept_verbatim() {
317 let mut env = envelope();
318 env.job_type = "a".repeat(MAX_SHORT_STRING_LENGTH);
319 let props = props_for(&env);
320 assert_eq!(
321 props.kind().as_ref().map(ShortString::to_string),
322 Some(env.job_type)
323 );
324 }
325}