Skip to main content

cortexkit_bus_trait/
work_queue.rs

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/// A claimant on a bounded work queue.
33///
34/// `ack`, `nak` and `term` return `Ok` only once the server has recorded the
35/// decision, so a claimant may exit straight after one without losing it.
36#[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/// The business family, not the transport's retention policy, selects the
80/// diagnostic stream. Non-session effects still use the effect dead-letter lane.
81#[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    /// `account` must already satisfy the bus naming account-token rules.
99    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
107/// Persistently publishes a cursor's terminal diagnostic before terminating it.
108///
109/// Call only after recording terminal business disposition. On redelivery, check
110/// that disposition first and repeat this helper, never the work. The diagnostic
111/// id includes the original subject and message id to avoid cross-family id
112/// collisions in the shared stream. Broker deduplication applies within the
113/// configured duplicate window; lifetime idempotency belongs to the business store.
114///
115/// With unlimited server delivery and a terminal receipt checked before work,
116/// repeated crashes cannot exhaust terminalization retries or rerun the work.
117/// Retry this helper until term succeeds: a crash gap longer than the 120-second
118/// EFFECT_DEAD / DELIVERY_DEAD window (`DEAD_LETTER_DUPLICATE_WINDOW` in bus-naming)
119/// may append a second diagnostic with the same deterministic id. Command-stream
120/// relay republishes use the server default window; durable acceptance records
121/// keyed by command id and checked before work, not broker duplicate history,
122/// are the authoritative guard against duplicate execution.
123pub 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
162/// Publishes a persistent dead-letter record before terminating the exhausted delivery.
163/// Returns the publish receipt, including duplicates, as described by [`Stream::publish`].
164/// A duplicate is also followed by termination; use a separate dead-letter stream
165/// so its message ids do not collide with the original work items.
166pub 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}