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}