Skip to main content

ruststream_pulsar/
message.rs

1//! [`PulsarMessage`] and the mapping between `RustStream` headers and message properties.
2//!
3//! Message properties carry headers directly, one property per header, and the partition key
4//! rides the message's own `partition_key` in both directions.
5
6use std::collections::HashMap;
7use std::future::{Future, ready};
8use std::time::Duration;
9
10use bytes::Bytes;
11use pulsar::proto::MessageIdData;
12use ruststream::{
13    AckError, BytesMut, HeaderMap, IncomingMessage, OutgoingMessage, Partitioned, Positioned, Str,
14};
15use tokio::sync::{mpsc, oneshot};
16
17use crate::error::PulsarError;
18#[cfg(feature = "testing")]
19use crate::in_process::{Delivery, Settlement};
20use crate::subscriber::PulsarSeeker;
21
22/// Header carrying the partition key, mapped onto the message's `partition_key` (which
23/// `KeyShared` subscriptions order by).
24///
25/// Mirrors the in-memory broker's convention, so services can switch brokers without changing
26/// their headers.
27pub const PARTITION_KEY_HEADER: &str = "partition-key";
28
29/// How a delivered message asks its driver task to settle it.
30#[derive(Debug)]
31pub(crate) enum SettleKind {
32    /// Acknowledge the delivery.
33    Ack,
34    /// Ask the broker to redeliver it (negative acknowledgement).
35    Nack,
36}
37
38/// A position in a topic's retained log, accepted by
39/// [`Seeker::seek`](ruststream::Seeker::seek).
40///
41/// Captured positions ([`Positioned::position`]) carry the pinned semantics the framework
42/// defines: seeking to one redelivers exactly that message. The timestamp form keeps the
43/// broker's own publish-time semantics instead.
44///
45/// The constructors exist because the `start_at(..)` clause of `#[subscriber]` recovers the
46/// position type from the constructor path; a bare variant path does not name its type in the
47/// tokens the macro sees.
48///
49/// # Examples
50///
51/// ```
52/// # mod demo {
53/// use ruststream_pulsar::prelude::*;
54/// use serde::Deserialize;
55///
56/// #[derive(Deserialize)]
57/// struct Tick {
58///     price: f64,
59///     replay_from: Option<u64>,
60/// }
61///
62/// /// A price board: the backlog is stale on startup, so the subscription opens at the tip, and
63/// /// a tick that asks for a replay rewinds it to a point in time.
64/// #[subscriber(
65///     PulsarSubscription::new("ticks", "board"),
66///     start_at(PulsarPosition::latest())
67/// )]
68/// async fn show(tick: &Tick, Ctx(seeker): Ctx<SeekHandle>) -> HandlerOutcome {
69///     tracing::info!(price = tick.price, "tick shown");
70///     if let Some(millis) = tick.replay_from
71///         && seeker.seek(PulsarPosition::timestamp(millis)).await.is_err()
72///     {
73///         return HandlerOutcome::retry();
74///     }
75///     HandlerOutcome::ack()
76/// }
77/// # }
78/// # fn main() {}
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum PulsarPosition {
82    /// The beginning of the log: every message the topics still retain is redelivered.
83    ///
84    /// This is a seek, not the server-side initial position, which Pulsar applies only when a
85    /// subscription is first created. A subscription is durable broker-side state, so a
86    /// `start_at(PulsarPosition::earliest())` clause rewinds an existing subscription's cursor
87    /// on every startup, replaying the retained backlog each time the service starts.
88    Earliest,
89    /// The tip of the log: only messages published after the seek are delivered.
90    Latest,
91    /// The position of a delivered message.
92    MessageId(MessageIdData),
93    /// Publish time, in milliseconds since the Unix epoch.
94    Timestamp(u64),
95}
96
97impl PulsarPosition {
98    /// The beginning of the log; see [`PulsarPosition::Earliest`] for how it interacts with a
99    /// durable subscription's cursor.
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// # mod demo {
105    /// use ruststream_pulsar::prelude::*;
106    /// use serde::Deserialize;
107    ///
108    /// #[derive(Deserialize)]
109    /// struct Job {
110    ///     id: u64,
111    ///     resume_at: Option<u64>,
112    /// }
113    ///
114    /// /// Reads the whole retained backlog on every startup, and skips forward past a region the
115    /// /// producer marked poisoned.
116    /// #[subscriber(
117    ///     PulsarSubscription::pattern("persistent://public/default/orders-.*", "audit"),
118    ///     start_at(PulsarPosition::earliest())
119    /// )]
120    /// async fn audit(job: &Job, Ctx(seeker): Ctx<SeekHandle>) -> HandlerOutcome {
121    ///     tracing::info!(job.id, "audited");
122    ///     if let Some(millis) = job.resume_at
123    ///         && seeker.seek(PulsarPosition::timestamp(millis)).await.is_err()
124    ///     {
125    ///         return HandlerOutcome::retry();
126    ///     }
127    ///     HandlerOutcome::ack()
128    /// }
129    /// # }
130    /// # fn main() {}
131    /// ```
132    #[must_use]
133    pub fn earliest() -> Self {
134        Self::Earliest
135    }
136
137    /// The tip of the log; see [`PulsarPosition::Latest`].
138    #[must_use]
139    pub fn latest() -> Self {
140        Self::Latest
141    }
142
143    /// A publish time, in milliseconds since the Unix epoch; see
144    /// [`PulsarPosition::Timestamp`].
145    #[must_use]
146    pub fn timestamp(millis: u64) -> Self {
147        Self::Timestamp(millis)
148    }
149}
150
151/// A repositioning request shipped from a seeker handle to the subscription's driver task.
152#[derive(Debug)]
153pub(crate) struct SeekCmd {
154    pub(crate) position: PulsarPosition,
155    pub(crate) done: oneshot::Sender<Result<(), PulsarError>>,
156}
157
158/// A settlement request shipped from a message handle to the subscription's driver task
159/// (the client's ack API needs `&mut Consumer`, which the driver owns).
160#[derive(Debug)]
161pub(crate) struct SettleCmd {
162    pub(crate) topic: String,
163    pub(crate) id: MessageIdData,
164    pub(crate) kind: SettleKind,
165    pub(crate) done: oneshot::Sender<Result<(), AckError>>,
166}
167
168/// A delayed negative acknowledgement shipped from a message handle to the subscription's driver
169/// task, which waits it out on its own runtime, the one the broker connected on.
170#[derive(Debug)]
171pub(crate) struct NackAfterCmd {
172    pub(crate) topic: String,
173    pub(crate) id: MessageIdData,
174    pub(crate) delay: Duration,
175    /// The delivery's own channel back to the driver. The wait holds it, so the driver keeps
176    /// serving settlements until the negative acknowledgement has gone out.
177    pub(crate) back: SettleSender,
178}
179
180/// Everything the driver task can be asked to do while its stream runs.
181#[derive(Debug)]
182pub(crate) enum DriverCmd {
183    Settle(SettleCmd),
184    NackAfter(NackAfterCmd),
185    Seek(SeekCmd),
186}
187
188pub(crate) type SettleSender = mpsc::UnboundedSender<DriverCmd>;
189
190/// How a delivery settles: through its subscription's driver task, or, under the `testing`
191/// feature, on the in-process transport it came from.
192///
193/// Without the feature there is one variant, so the type is the driver channel itself.
194#[derive(Debug)]
195enum Settle {
196    Driver(SettleSender),
197    #[cfg(feature = "testing")]
198    InProcess(Box<Settlement>),
199}
200
201// The zero-cost promise of the in-process mode, held by the compiler: a build without it gives
202// a delivery's settlement exactly the size of the driver channel it wraps.
203#[cfg(not(feature = "testing"))]
204const _: () = assert!(size_of::<Settle>() == size_of::<SettleSender>());
205
206/// Refuses a delay the consumer's own timer would cut short.
207///
208/// A delayed retry is held in this process, so the delivery stays unacknowledged for the whole
209/// delay. A consumer with an acknowledgement timeout redelivers an unacknowledged message on its
210/// own once that timeout elapses, which would bring the message back before the delay is over.
211/// The call says so rather than returning a delay it cannot keep.
212pub(crate) fn within_ack_timeout(
213    delay: Duration,
214    ack_timeout: Option<Duration>,
215    topic: &str,
216) -> Result<(), AckError> {
217    match ack_timeout {
218        Some(timeout) if delay >= timeout => Err(AckError::Broker(Box::from(format!(
219            "topic '{topic}': a retry delay of {delay:?} is not shorter than the subscription's \
220             ack_timeout of {timeout:?}. A delayed retry is held in this process, and the \
221             consumer redelivers an unacknowledged message once the ack timeout elapses, so the \
222             message would come back early. Raise ack_timeout above the delay, or shorten it"
223        )))),
224        _ => Ok(()),
225    }
226}
227
228/// A message delivered by a [`PulsarSubscriber`](crate::PulsarSubscriber).
229///
230/// `ack` acknowledges; `nack(requeue = true)` asks the broker to redeliver, which is what
231/// drives the delivery count towards the subscription's dead-letter policy.
232/// `nack(requeue = false)` acknowledges too: Pulsar settles a message by acknowledging it or
233/// asking for it again, and a poison message leaves through the dead-letter policy, reached by
234/// repeated redelivery. The client queues acknowledgements asynchronously, so `Ok` means
235/// "queued", not "broker confirmed".
236///
237/// `nack_after(delay)` is the delayed form, and the delay is this process's: Pulsar's negative
238/// acknowledgement carries none, so the delivery is held unacknowledged until the delay is over
239/// and the negative acknowledgement goes out then. The redelivery itself is the broker's, and so
240/// is the count it advances.
241pub struct PulsarMessage {
242    payload: Bytes,
243    headers: HeaderMap,
244    topic: String,
245    id: MessageIdData,
246    settle: Settle,
247    /// The subscription's acknowledgement timeout, which bounds how long a delayed retry may
248    /// hold the delivery before the consumer redelivers it anyway.
249    ack_timeout: Option<Duration>,
250}
251
252impl std::fmt::Debug for PulsarMessage {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        f.debug_struct("PulsarMessage")
255            .field("topic", &self.topic)
256            .field("payload_len", &self.payload.len())
257            .finish_non_exhaustive()
258    }
259}
260
261impl PulsarMessage {
262    pub(crate) fn new(
263        message: &pulsar::consumer::Message<Vec<u8>>,
264        settle: SettleSender,
265        ack_timeout: Option<Duration>,
266    ) -> Self {
267        let metadata = message.metadata();
268        let mut headers = HeaderMap::with_capacity(metadata.properties.len() + 1);
269        for kv in &metadata.properties {
270            headers.insert(kv.key.clone(), kv.value.clone());
271        }
272        if let Some(key) = &metadata.partition_key {
273            // A header key is a shared string: the static form hands the map the constant itself
274            // rather than a copy of it made on every delivery.
275            headers.insert(Str::from_static(PARTITION_KEY_HEADER), key.clone());
276        }
277        Self {
278            payload: Bytes::copy_from_slice(&message.payload.data),
279            headers,
280            topic: message.topic.clone(),
281            id: message.message_id().clone(),
282            settle: Settle::Driver(settle),
283            ack_timeout,
284        }
285    }
286
287    /// A delivery of the in-process transport, reporting what a live one reports: its topic
288    /// as the subscription named it (`topic`), and a message id whose entry id is its place in
289    /// the topic's log.
290    #[cfg(feature = "testing")]
291    pub(crate) fn in_process(
292        delivery: Delivery,
293        topic: String,
294        settlement: Settlement,
295        ack_timeout: Option<Duration>,
296    ) -> Self {
297        let entry_id = u64::try_from(settlement.seq()).unwrap_or(u64::MAX);
298        Self {
299            payload: delivery.payload,
300            headers: delivery.headers,
301            topic,
302            id: MessageIdData {
303                ledger_id: 0,
304                entry_id,
305                // The sentinel addresses the topic as a whole, as the end-of-log marks do.
306                partition: Some(-1),
307                ..MessageIdData::default()
308            },
309            settle: Settle::InProcess(Box::new(settlement)),
310            ack_timeout,
311        }
312    }
313
314    /// The topic this message arrived on, named the way its subscription named it: as written
315    /// in [`PulsarSubscription::new`](crate::PulsarSubscription::new) or
316    /// [`topics`](crate::PulsarSubscription::topics), and by its full name for a
317    /// [`pattern`](crate::PulsarSubscription::pattern), whose topics the client finds by listing
318    /// their full names. A partitioned topic adds its partition suffix.
319    #[must_use]
320    pub fn topic(&self) -> &str {
321        &self.topic
322    }
323
324    /// The handle that repositions this delivery's subscription. A live one is minted off the
325    /// channel to the subscription's driver task, which owns both settlement and seeking, so a
326    /// delivery carries the reposition handle without the subscriber stamping one onto every
327    /// message.
328    pub(crate) fn seeker(&self) -> PulsarSeeker {
329        match &self.settle {
330            Settle::Driver(driver) => PulsarSeeker::new(driver.clone()),
331            #[cfg(feature = "testing")]
332            Settle::InProcess(settlement) => settlement.seeker(),
333        }
334    }
335}
336
337/// Asks the subscription's driver task to settle the delivery `id` of `topic`, and waits for its
338/// answer.
339pub(crate) async fn send_settle(
340    driver: SettleSender,
341    topic: String,
342    id: MessageIdData,
343    kind: SettleKind,
344) -> Result<(), AckError> {
345    let (done, wait) = oneshot::channel();
346    driver
347        .send(DriverCmd::Settle(SettleCmd {
348            topic,
349            id,
350            kind,
351            done,
352        }))
353        .map_err(|_| AckError::Broker(Box::from("the subscription's driver task has shut down")))?;
354    wait.await
355        .map_err(|_| AckError::Broker(Box::from("the subscription's driver task has shut down")))?
356}
357
358impl Positioned for PulsarMessage {
359    type Position = PulsarPosition;
360
361    fn position(&self) -> PulsarPosition {
362        PulsarPosition::MessageId(self.id.clone())
363    }
364}
365
366impl Partitioned for PulsarMessage {
367    fn partition_key(&self) -> Option<&[u8]> {
368        self.headers.get(PARTITION_KEY_HEADER)
369    }
370}
371
372impl IncomingMessage for PulsarMessage {
373    fn payload(&self) -> &[u8] {
374        &self.payload
375    }
376
377    fn headers(&self) -> &HeaderMap {
378        &self.headers
379    }
380
381    async fn ack(self) -> Result<(), AckError> {
382        match self.settle {
383            Settle::Driver(driver) => {
384                send_settle(driver, self.topic, self.id, SettleKind::Ack).await
385            }
386            #[cfg(feature = "testing")]
387            Settle::InProcess(settlement) => {
388                settlement.settle();
389                Ok(())
390            }
391        }
392    }
393
394    async fn nack(self, requeue: bool) -> Result<(), AckError> {
395        // Acknowledging IS the drop: Pulsar has no terminal reject, and the dead-letter policy
396        // owns poison-message routing via repeated redelivery.
397        let kind = if requeue {
398            SettleKind::Nack
399        } else {
400            SettleKind::Ack
401        };
402        match self.settle {
403            Settle::Driver(driver) => send_settle(driver, self.topic, self.id, kind).await,
404            #[cfg(feature = "testing")]
405            Settle::InProcess(settlement) => {
406                if requeue {
407                    (*settlement).requeue(self.payload, self.headers)
408                } else {
409                    settlement.settle();
410                    Ok(())
411                }
412            }
413        }
414    }
415
416    /// The delay is kept by this process rather than by the broker, and that is still native
417    /// delayed redelivery as far as the framework is concerned: no copy of the message is
418    /// published, and the redelivery that follows is the broker's own, counted by the consumer's
419    /// dead-letter policy like any other.
420    fn supports_nack_after(&self) -> bool {
421        true
422    }
423
424    /// Holds the delivery unacknowledged for `delay`, then negatively acknowledges it so the
425    /// broker redelivers.
426    ///
427    /// The wait runs on a task of its own, started by the subscription's driver on the runtime
428    /// the broker connected on: the dispatch loop must not stop for it, and a handler on a
429    /// dedicated thread may settle from a runtime that stops before the delay is out. The
430    /// delivery's settlement channel keeps the driver alive until the wait is over. `Ok`
431    /// therefore means the delay was accepted, not that the redelivery has happened yet.
432    ///
433    /// # Errors
434    ///
435    /// Returns [`AckError::Broker`] when the delay is not shorter than the subscription's
436    /// `ack_timeout`, which would redeliver the message before the delay was over, or when the
437    /// subscription is gone (its driver task has shut down, or in process the broker did).
438    ///
439    /// # Cancel safety
440    ///
441    /// At-most-once over the delay window: the message is unacknowledged throughout, so a process
442    /// that exits before the wait ends loses only the wait. The broker redelivers the message
443    /// once the consumer's `ack_timeout` elapses, and at once where the consumer disconnects.
444    fn nack_after(self, delay: Duration) -> impl Future<Output = Result<(), AckError>> + Send {
445        if let Err(err) = within_ack_timeout(delay, self.ack_timeout, &self.topic) {
446            return ready(Err(err));
447        }
448        match self.settle {
449            Settle::Driver(driver) => {
450                let cmd = DriverCmd::NackAfter(NackAfterCmd {
451                    topic: self.topic,
452                    id: self.id,
453                    delay,
454                    back: driver.clone(),
455                });
456                if driver.send(cmd).is_err() {
457                    return ready(Err(AckError::Broker(Box::from(
458                        "the subscription's driver task has shut down",
459                    ))));
460                }
461            }
462            #[cfg(feature = "testing")]
463            Settle::InProcess(settlement) => {
464                return ready((*settlement).nack_after(delay, self.payload, self.headers));
465            }
466        }
467        ready(Ok(()))
468    }
469
470    fn partition_key(&self) -> Option<&[u8]> {
471        Partitioned::partition_key(self)
472    }
473}
474
475/// Builds the client message for an outgoing publish.
476///
477/// `key` is the partition key the call site named with
478/// [`partition_key`](crate::PulsarPublishSteps::partition_key); it wins over a
479/// [`PARTITION_KEY_HEADER`] header the call site wrote itself, and with neither the message
480/// leaves unkeyed. Either way the key becomes the message's own `partition_key` rather than a
481/// property, which is how it comes back on delivery.
482///
483/// # Errors
484///
485/// Returns [`PulsarError::Publish`] for a header value that is not UTF-8. Pulsar carries
486/// properties as text, so such a value cannot arrive as it was written, and a rewritten header
487/// is a silent loss.
488pub(crate) fn to_pulsar_message(
489    msg: OutgoingMessage<'_, BytesMut>,
490    key: Option<&str>,
491) -> Result<pulsar::producer::Message, PulsarError> {
492    let (topic, payload, headers) = msg.into_parts();
493    let mut properties = HashMap::with_capacity(headers.len());
494    let mut partition_key = key.map(ToOwned::to_owned);
495    for (name, value) in headers.iter() {
496        let text = std::str::from_utf8(value)
497            .map_err(|err| PulsarError::Publish {
498                topic: topic.to_owned(),
499                source: Box::from(format!(
500                    "header '{name}' is not UTF-8 ({err}); Pulsar carries properties as text"
501                )),
502            })?
503            .to_owned();
504        if name == PARTITION_KEY_HEADER {
505            partition_key.get_or_insert(text);
506        } else {
507            properties.insert(name.to_owned(), text);
508        }
509    }
510    Ok(pulsar::producer::Message {
511        // The client owns the payload, and the buffer the framework wrote is the vector it
512        // wants: taking it costs nothing where a copy costs the whole body.
513        payload: Vec::from(payload),
514        properties,
515        partition_key,
516        ..Default::default()
517    })
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn partition_key_header_becomes_the_partition_key() {
526        let mut headers = HeaderMap::new();
527        headers.insert(PARTITION_KEY_HEADER, "user-42");
528        headers.insert("x-tenant", "acme");
529        let outgoing = OutgoingMessage::new("orders", b"{}".as_slice()).with_headers(headers);
530
531        let message = to_pulsar_message(outgoing, None).expect("text headers are carried");
532        assert_eq!(message.partition_key.as_deref(), Some("user-42"));
533        assert_eq!(
534            message.properties.get("x-tenant").map(String::as_str),
535            Some("acme")
536        );
537        assert!(!message.properties.contains_key(PARTITION_KEY_HEADER));
538    }
539
540    #[test]
541    fn the_call_site_key_wins_over_the_header() {
542        let mut headers = HeaderMap::new();
543        headers.insert(PARTITION_KEY_HEADER, "user-7");
544        let outgoing = OutgoingMessage::new("orders", b"{}".as_slice()).with_headers(headers);
545
546        let message =
547            to_pulsar_message(outgoing, Some("user-42")).expect("text headers are carried");
548        assert_eq!(message.partition_key.as_deref(), Some("user-42"));
549        assert!(!message.properties.contains_key(PARTITION_KEY_HEADER));
550    }
551
552    /// The boundary is exclusive: a delay equal to the timeout would race the consumer's own
553    /// redelivery, and the call refuses rather than picking a winner.
554    #[test]
555    fn a_delay_is_bounded_by_the_ack_timeout() {
556        let timeout = Duration::from_secs(30);
557        assert!(within_ack_timeout(Duration::from_secs(29), Some(timeout), "orders").is_ok());
558        assert!(within_ack_timeout(Duration::from_mins(5), None, "orders").is_ok());
559
560        let refused = within_ack_timeout(timeout, Some(timeout), "orders")
561            .expect_err("a delay equal to the timeout must be refused");
562        let message = format!("{refused:?}");
563        assert!(message.contains("ack_timeout"), "{message}");
564    }
565
566    /// The refusal is what a handler's log shows, so it reads as one sentence with single spaces.
567    #[test]
568    fn the_ack_timeout_refusal_reads_as_one_sentence() {
569        let timeout = Duration::from_secs(30);
570        let AckError::Broker(source) = within_ack_timeout(timeout, Some(timeout), "orders")
571            .expect_err("a delay equal to the timeout must be refused")
572        else {
573            panic!("the refusal is the broker's own");
574        };
575        assert_eq!(
576            source.to_string(),
577            "topic 'orders': a retry delay of 30s is not shorter than the subscription's \
578             ack_timeout of 30s. A delayed retry is held in this process, and the consumer \
579             redelivers an unacknowledged message once the ack timeout elapses, so the message \
580             would come back early. Raise ack_timeout above the delay, or shorten it",
581        );
582    }
583
584    /// The client's message owns its payload, so the publish hands the buffer the framework
585    /// wrote over rather than copying it. Address equality is the proof: a copy lands elsewhere.
586    #[test]
587    fn the_client_message_takes_the_buffer_the_publish_wrote() {
588        let buffer = BytesMut::from(&b"{\"id\":1}"[..]);
589        let at = buffer.as_ptr();
590        let outgoing = OutgoingMessage::produced("orders", buffer);
591
592        let message = to_pulsar_message(outgoing, None).expect("text headers are carried");
593
594        assert_eq!(
595            message.payload.as_ptr(),
596            at,
597            "the payload must be the buffer the publish wrote, not a copy of it",
598        );
599    }
600
601    #[test]
602    fn a_publish_that_names_no_key_leaves_unkeyed() {
603        let outgoing = OutgoingMessage::new("orders", b"{}".as_slice());
604
605        let message = to_pulsar_message(outgoing, None).expect("text headers are carried");
606        assert_eq!(message.partition_key, None);
607    }
608
609    /// Pulsar carries properties as text, so a header value that is not UTF-8 is refused rather
610    /// than rewritten on the way out.
611    #[test]
612    fn a_header_that_is_not_text_is_refused() {
613        let mut headers = HeaderMap::new();
614        headers.insert("x-raw", vec![0xff, b'a']);
615        let outgoing = OutgoingMessage::new("orders", b"{}".as_slice()).with_headers(headers);
616
617        let refused = to_pulsar_message(outgoing, None).expect_err("a binary header is refused");
618        assert!(
619            matches!(&refused, PulsarError::Publish { topic, .. } if topic == "orders"),
620            "{refused:?}",
621        );
622    }
623}