1use std::time::Duration;
2
3use async_trait::async_trait;
4
5use crate::{BusResult, ContentDigest, Headers, Message, PublishReceipt, Stream};
6
7pub const DEAD_LETTER_REASON_MAX_DELIVERIES: &str = "max_deliveries_exceeded";
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
10pub struct DeliveryToken(pub u64);
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct WorkItem {
14 pub token: DeliveryToken,
15 pub message: Message,
16 pub delivery_count: u32,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct MaxDeliveriesExceeded {
21 pub item: WorkItem,
22 pub max_deliveries: u32,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ClaimOutcome {
27 Item(WorkItem),
28 Empty,
29 MaxDeliveriesExceeded(MaxDeliveriesExceeded),
30}
31
32#[async_trait]
37pub trait WorkQueue: Send + Sync {
38 async fn claim(&self) -> BusResult<ClaimOutcome>;
39 async fn ack(&self, token: DeliveryToken) -> BusResult<()>;
40 async fn nak(&self, token: DeliveryToken, delay: Duration) -> BusResult<()>;
41 async fn term(&self, token: DeliveryToken) -> BusResult<()>;
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct DeadLetterRecord {
46 pub original_subject: String,
47 pub message_id: String,
48 pub digest: ContentDigest,
49 pub delivery_count: u32,
50 pub reason: String,
51}
52
53impl DeadLetterRecord {
54 pub fn from_exhaustion(exhausted: &MaxDeliveriesExceeded) -> Self {
55 Self {
56 original_subject: exhausted.item.message.subject.clone(),
57 message_id: exhausted.item.message.id.clone(),
58 digest: exhausted.item.message.digest,
59 delivery_count: exhausted.item.delivery_count,
60 reason: DEAD_LETTER_REASON_MAX_DELIVERIES.into(),
61 }
62 }
63
64 pub fn headers(&self) -> Headers {
65 Headers::from([
66 ("original-subject".into(), self.original_subject.clone()),
67 ("message-id".into(), self.message_id.clone()),
68 ("content-digest".into(), self.digest.to_string()),
69 ("delivery-count".into(), self.delivery_count.to_string()),
70 ("reason".into(), self.reason.clone()),
71 ])
72 }
73}
74
75pub fn effect_dead_subject(account: &str) -> String {
76 format!("ck.{account}.effect.dead")
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83pub enum DeliveryFamily {
84 Effect,
85 Inbox,
86 Room,
87 Wake,
88 Peer,
89 Consult,
90 Work,
91 Campaign,
92 Hire,
93 Spec,
94 Push,
95}
96
97impl DeliveryFamily {
98 pub fn dead_subject(self, account: &str) -> String {
100 match self {
101 Self::Effect => effect_dead_subject(account),
102 _ => format!("ck.{account}.delivery.dead"),
103 }
104 }
105}
106
107pub async fn terminally_dispose_cursor<S, C>(
124 stream: &S,
125 cursor: &mut C,
126 account: &str,
127 family: DeliveryFamily,
128 delivery: &crate::StreamDelivery,
129 reason: &str,
130 dead_letter_digest: ContentDigest,
131) -> BusResult<PublishReceipt>
132where
133 S: Stream,
134 C: crate::StreamCursor,
135{
136 let message = &delivery.message;
137 let id = format!(
138 "{}:{}:{}",
139 message.subject.len(),
140 message.subject,
141 message.id
142 );
143 let headers = Headers::from([
144 ("original-subject".into(), message.subject.clone()),
145 ("message-id".into(), message.id.clone()),
146 ("content-digest".into(), message.digest.to_string()),
147 ("delivery-count".into(), delivery.delivery_count.to_string()),
148 ("reason".into(), reason.into()),
149 ]);
150 let receipt = stream
151 .publish(
152 &family.dead_subject(account),
153 &id,
154 dead_letter_digest,
155 headers,
156 )
157 .await?;
158 cursor.term(Some(reason)).await?;
159 Ok(receipt)
160}
161
162pub async fn terminally_dispose<S, Q>(
167 stream: &S,
168 queue: &Q,
169 account: &str,
170 exhausted: &MaxDeliveriesExceeded,
171 dead_letter_digest: ContentDigest,
172) -> BusResult<PublishReceipt>
173where
174 S: Stream,
175 Q: WorkQueue,
176{
177 let record = DeadLetterRecord::from_exhaustion(exhausted);
178 let receipt = stream
179 .publish(
180 &effect_dead_subject(account),
181 &record.message_id,
182 dead_letter_digest,
183 record.headers(),
184 )
185 .await?;
186 queue.term(exhausted.item.token).await?;
187 Ok(receipt)
188}
189
190#[cfg(test)]
191mod cursor_disposal_tests {
192 use super::*;
193 use crate::{BusError, DurableOwner, StreamCursor, StreamDelivery};
194 use std::sync::{Arc, Mutex};
195
196 #[derive(Default)]
197 struct RecordingCursor(Arc<Mutex<Vec<String>>>);
198
199 #[async_trait]
200 impl StreamCursor for RecordingCursor {
201 async fn next(&mut self) -> BusResult<Option<StreamDelivery>> {
202 panic!("disposal must not pull work")
203 }
204 async fn ack(&mut self) -> BusResult<()> {
205 panic!("disposal must not ack work")
206 }
207 async fn nak(&mut self, _: Duration) -> BusResult<()> {
208 panic!("disposal must not retry work")
209 }
210 async fn in_progress(&mut self) -> BusResult<()> {
211 panic!("disposal must not prolong work")
212 }
213 async fn term(&mut self, reason: Option<&str>) -> BusResult<()> {
214 self.0
215 .lock()
216 .unwrap()
217 .push(format!("term:{}", reason.unwrap()));
218 Ok(())
219 }
220 }
221
222 struct RecordingStream {
223 events: Arc<Mutex<Vec<String>>>,
224 fail_publish: bool,
225 }
226
227 #[async_trait]
228 impl Stream for RecordingStream {
229 type Cursor = RecordingCursor;
230 async fn consumer(&self, _: DurableOwner<'_>) -> BusResult<Self::Cursor> {
231 panic!("disposal must not bind work")
232 }
233 async fn publish(
234 &self,
235 subject: &str,
236 id: &str,
237 _: ContentDigest,
238 headers: Headers,
239 ) -> BusResult<PublishReceipt> {
240 self.events.lock().unwrap().push(format!(
241 "publish:{subject}:{id}:{}",
242 headers["delivery-count"]
243 ));
244 if self.fail_publish {
245 Err(BusError::unavailable("publish failed"))
246 } else {
247 Ok(PublishReceipt::new(42, true))
248 }
249 }
250 }
251
252 fn delivery(subject: &str) -> StreamDelivery {
253 StreamDelivery {
254 stream_seq: 1,
255 delivery_count: u64::from(u32::MAX) + 1,
256 message: Message {
257 subject: subject.into(),
258 id: "same-id".into(),
259 digest: ContentDigest::of_bytes(b"body"),
260 headers: Headers::new(),
261 },
262 }
263 }
264
265 #[tokio::test]
266 async fn cursor_disposal_does_not_term_when_publish_fails() {
267 let mut cursor = RecordingCursor::default();
268 let stream = RecordingStream {
269 events: cursor.0.clone(),
270 fail_publish: true,
271 };
272 assert!(terminally_dispose_cursor(
273 &stream,
274 &mut cursor,
275 "box",
276 DeliveryFamily::Inbox,
277 &delivery("ck.box.inbox.head.target.ready"),
278 "terminal",
279 ContentDigest::of_bytes(b"record")
280 )
281 .await
282 .is_err());
283 let events = cursor.0.lock().unwrap();
284 assert_eq!(events.len(), 1);
285 assert!(events[0].starts_with("publish:ck.box.delivery.dead:"));
286 }
287
288 #[tokio::test]
289 async fn cursor_disposal_routes_before_term_and_preserves_unlimited_counts() {
290 for family in [
291 DeliveryFamily::Effect,
292 DeliveryFamily::Inbox,
293 DeliveryFamily::Room,
294 DeliveryFamily::Wake,
295 DeliveryFamily::Peer,
296 DeliveryFamily::Consult,
297 DeliveryFamily::Work,
298 DeliveryFamily::Campaign,
299 DeliveryFamily::Hire,
300 DeliveryFamily::Spec,
301 DeliveryFamily::Push,
302 ] {
303 let mut cursor = RecordingCursor::default();
304 let stream = RecordingStream {
305 events: cursor.0.clone(),
306 fail_publish: false,
307 };
308 let receipt = terminally_dispose_cursor(
309 &stream,
310 &mut cursor,
311 "box",
312 family,
313 &delivery("original"),
314 "terminal",
315 ContentDigest::of_bytes(b"record"),
316 )
317 .await
318 .unwrap();
319 assert!(receipt.duplicate());
320 assert_eq!(receipt.stream_sequence(), 42);
321 let events = cursor.0.lock().unwrap();
322 let expected_subject = if family == DeliveryFamily::Effect {
323 "ck.box.effect.dead"
324 } else {
325 "ck.box.delivery.dead"
326 };
327 assert_eq!(
328 *events,
329 [
330 format!("publish:{expected_subject}:8:original:same-id:4294967296"),
331 "term:terminal".into()
332 ]
333 );
334 }
335 }
336
337 #[tokio::test]
338 async fn cursor_disposal_ids_are_stable_and_distinguish_source_subjects() {
339 let mut cursor = RecordingCursor::default();
340 let stream = RecordingStream {
341 events: cursor.0.clone(),
342 fail_publish: false,
343 };
344 for subject in ["source-one", "source-two", "source-one"] {
345 let receipt = terminally_dispose_cursor(
346 &stream,
347 &mut cursor,
348 "box",
349 DeliveryFamily::Inbox,
350 &delivery(subject),
351 "terminal",
352 ContentDigest::of_bytes(b"record"),
353 )
354 .await
355 .unwrap();
356 assert!(receipt.duplicate());
357 }
358 let events = cursor.0.lock().unwrap();
359 assert_eq!(events[0], events[4]);
360 assert_ne!(events[0], events[2]);
361 }
362}