Skip to main content

ruststream_pulsar/
publisher.rs

1//! [`PulsarPublisher`], its [`PulsarPublish`] policy, and the per-message settings a call site
2//! adjusts ([`PulsarPublishOptions`], [`PulsarPublishSteps`]).
3
4// Without the `testing` feature a transport has one variant, so a `match` on it has a single
5// arm; the matches stay so that the in-process arm has its place when the feature is on.
6#![cfg_attr(
7    not(feature = "testing"),
8    allow(clippy::infallible_destructuring_match)
9)]
10
11use std::future::{Future, ready};
12use std::sync::Arc;
13
14use pulsar::routing_policy::RoutingPolicy;
15use pulsar::{ProducerOptions, Pulsar, TokioExecutor};
16#[cfg(feature = "asyncapi")]
17use ruststream::asyncapi::Bindings;
18use ruststream::runtime::{PublishBuilder, PublishSink};
19use ruststream::{BytesMut, OutgoingMessage, PairError, PublishPolicy, Publisher, Take};
20use tokio::sync::Mutex;
21
22#[cfg(feature = "asyncapi")]
23use crate::bindings::topic_channel;
24use crate::broker::{ConnectedPulsarBroker, Core, CoreCell, Transport};
25use crate::error::{PulsarError, box_err};
26use crate::message::to_pulsar_message;
27use crate::topic::PulsarTopic;
28
29pub(crate) type PulsarProducer = pulsar::Producer<TokioExecutor>;
30
31/// The producer options every producer this crate opens is built with: place a keyed message by
32/// its key.
33///
34/// A partitioned topic is many topics behind one name, and the producer picks the partition per
35/// message. The client's unset routing policy rotates through the partitions and never looks at
36/// the key, which sends one key's messages to every partition in turn and leaves a `KeyShared`
37/// subscription no per-key order to keep. Naming the policy is what makes the key place the
38/// message: it hashes a key when the message carries one, and rotates when it does not. A topic
39/// with no partitions has one producer and reads nothing here.
40fn keyed_routing() -> ProducerOptions {
41    ProducerOptions {
42        routing_policy: Some(RoutingPolicy::RoundRobin),
43        ..ProducerOptions::default()
44    }
45}
46
47/// What one Pulsar publish differs from the next in.
48///
49/// Every field is optional: a message carries what its call site named with a step of
50/// [`PulsarPublishSteps`], and nothing else. The [`PulsarPublish`] policy fixes no key of its
51/// own, so a publish that names no step goes unkeyed.
52///
53/// # Examples
54///
55/// A test reads them back off the slot the handler published through:
56///
57/// ```
58/// # #[cfg(feature = "testing")]
59/// # mod demo {
60/// use std::error::Error;
61///
62/// use ruststream::testing::TestApp;
63/// use ruststream_pulsar::prelude::*;
64/// use serde::{Deserialize, Serialize};
65///
66/// #[derive(Deserialize, Outgoing, Serialize)]
67/// struct Order {
68///     id: u64,
69/// }
70///
71/// #[derive(Outgoing, Serialize)]
72/// struct Receipt {
73///     id: u64,
74/// }
75///
76/// #[derive(OutSlot)]
77/// #[publishes(Receipt)]
78/// struct Ledger;
79///
80/// #[subscriber(PulsarSubscription::new("orders", "workers"))]
81/// async fn record(
82///     order: &Order,
83///     Out(ledger): Out<impl Publisher<Options = PulsarPublishOptions>, Ledger>,
84/// ) -> HandlerOutcome {
85///     let sent = ledger
86///         .message(&Receipt { id: order.id })
87///         .to("receipts")
88///         .partition_key(format!("user-{}", order.id))
89///         .publish()
90///         .await;
91///     if sent.is_err() {
92///         return HandlerOutcome::retry();
93///     }
94///     HandlerOutcome::ack()
95/// }
96///
97/// pub fn app() -> RustStream {
98///     RustStream::new(AppInfo::new("orders", "0.1.0"))
99///         .with_broker(PulsarBroker::new("pulsar://localhost:6650"), |b| {
100///             b.include(record).out(Ledger, Publish).build();
101///         })
102/// }
103///
104/// pub async fn a_receipt_is_keyed_by_its_user() -> Result<(), Box<dyn Error>> {
105///     let tb = TestApp::start(app()).await?;
106///     tb.broker::<PulsarBroker>()
107///         .message(&Order { id: 7 })
108///         .to("orders")
109///         .publish()
110///         .await?;
111///
112///     tb.out::<Ledger>()
113///         .assert_called_once()
114///         .with_options(&PulsarPublishOptions {
115///             partition_key: Some("user-7".to_owned()),
116///         });
117///     tb.shutdown().await?;
118///     Ok(())
119/// }
120/// # }
121/// # #[cfg(feature = "testing")]
122/// # #[tokio::main]
123/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
124/// #     demo::a_receipt_is_keyed_by_its_user().await
125/// # }
126/// # #[cfg(not(feature = "testing"))]
127/// # fn main() {}
128/// ```
129#[derive(Debug, Clone, Default, PartialEq, Eq)]
130pub struct PulsarPublishOptions {
131    /// The message's partition key: keyed routing places the message by it and `KeyShared`
132    /// subscriptions order by it.
133    ///
134    /// `None` leaves the key to the `partition-key` header, the portable spelling of the same
135    /// value; with neither, the message is unkeyed.
136    pub partition_key: Option<String>,
137}
138
139/// Publishes messages to Pulsar topics, one producer per topic, created lazily and shared
140/// through the broker core (so `shutdown` can close them).
141///
142/// The partition key travels per message: [`PulsarPublishSteps::partition_key`] names it at the
143/// call site, and a `partition-key` header names the same key for a caller that writes its
144/// headers itself. Awaits the broker's send receipt, so `Ok` means the
145/// broker stored the message. A payload over the broker's
146/// [`max_message_size`](crate::PulsarBroker::max_message_size) fails with
147/// [`PulsarError::Publish`] without being sent. Buildable before `connect` and usable until
148/// `shutdown`; afterwards every publish reports [`PulsarError::NotConnected`].
149#[derive(Clone)]
150pub struct PulsarPublisher {
151    cell: CoreCell,
152}
153
154impl std::fmt::Debug for PulsarPublisher {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("PulsarPublisher").finish_non_exhaustive()
157    }
158}
159
160impl PulsarPublisher {
161    pub(crate) fn new(cell: CoreCell) -> Self {
162        Self { cell }
163    }
164
165    fn core(&self) -> Result<&Core, PulsarError> {
166        let core = self.cell.get().ok_or(PulsarError::NotConnected)?;
167        core.ensure_open()?;
168        Ok(core)
169    }
170
171    /// The per-topic producer, created on first use and cached on the core.
172    // The map guard intentionally spans the build so two callers cannot race a double
173    // producer for the same topic.
174    #[allow(clippy::significant_drop_tightening)]
175    async fn producer_for(
176        &self,
177        core: &Core,
178        client: &Pulsar<TokioExecutor>,
179        topic: &str,
180    ) -> Result<Arc<Mutex<PulsarProducer>>, PulsarError> {
181        let full = PulsarTopic::parse(topic)?.as_str().to_owned();
182        let mut producers = core.producers.lock().await;
183        if let Some(producer) = producers.get(&full) {
184            return Ok(Arc::clone(producer));
185        }
186        // Built on the runtime the broker connected on: the client starts the connection a
187        // producer needs on the runtime that asks, and the first publish may come from a runtime
188        // that stops right after (a handler on a dedicated thread).
189        let (client, named) = (client.clone(), full.clone());
190        let producer = core
191            .runtime
192            .spawn(async move {
193                Box::pin(
194                    client
195                        .producer()
196                        .with_topic(named)
197                        .with_options(keyed_routing())
198                        .build(),
199                )
200                .await
201            })
202            .await
203            .map_err(|e| PulsarError::Publish {
204                topic: topic.to_owned(),
205                source: box_err(e),
206            })?
207            .map_err(|e| PulsarError::Publish {
208                topic: topic.to_owned(),
209                source: box_err(e),
210            })?;
211        let producer = Arc::new(Mutex::new(producer));
212        producers.insert(full, Arc::clone(&producer));
213        Ok(producer)
214    }
215}
216
217impl Publisher for PulsarPublisher {
218    // The client's message owns its payload as a `Vec<u8>`, so this transport keeps the buffer
219    // the framework wrote.
220    type Payload = Take;
221    type Error = PulsarError;
222    /// The partition key, the one value Pulsar lets one publish differ from the next in on this
223    /// crate's surface.
224    type Options = PulsarPublishOptions;
225
226    async fn publish(
227        &self,
228        msg: OutgoingMessage<'_, BytesMut>,
229        options: Option<&Self::Options>,
230    ) -> Result<(), Self::Error> {
231        let core = self.core()?;
232        let size = msg.payload().len();
233        if size > core.max_message_size {
234            return Err(PulsarError::Publish {
235                topic: msg.name().to_owned(),
236                source: Box::from(format!(
237                    "the payload is {size} bytes, over the {} the broker's max_message_size \
238                     allows",
239                    core.max_message_size
240                )),
241            });
242        }
243        let key = options.and_then(|options| options.partition_key.as_deref());
244        let client = match &core.transport {
245            Transport::Client(client) => client,
246            #[cfg(feature = "testing")]
247            Transport::InProcess(bus) => return bus.publish(msg, key),
248        };
249        // The destination outlives the message, so the client's message can consume it.
250        let topic = msg.name();
251        let producer = Box::pin(self.producer_for(core, client, topic)).await?;
252        let message = to_pulsar_message(msg, key)?;
253        let receipt = {
254            let mut producer = producer.lock().await;
255            Box::pin(producer.send_non_blocking(message))
256                .await
257                .map_err(|e| PulsarError::Publish {
258                    topic: topic.to_owned(),
259                    source: box_err(e),
260                })?
261        };
262        receipt.await.map(|_| ()).map_err(|e| PulsarError::Publish {
263            topic: topic.to_owned(),
264            source: box_err(e),
265        })
266    }
267}
268
269/// Pulsar's per-message steps on the framework's publish builder.
270///
271/// A step sets one field of [`PulsarPublishOptions`] for the publish being assembled and returns
272/// the builder, so the message still leaves through the entry the mount site named, with that
273/// entry's codec, transforms and slot attribution. The bound is on the publisher's settings type,
274/// so these steps appear on a builder over a Pulsar publisher and over no other broker's.
275///
276/// The trait is in the crate [prelude](crate::prelude). A handler body that names a step imports
277/// that glob and bounds its slot `Out<impl Publisher<Options = PulsarPublishOptions>, Marker>`;
278/// every other body imports the framework prelude alone.
279///
280/// # Examples
281///
282/// ```
283/// # mod demo {
284/// use ruststream_pulsar::prelude::*;
285/// use serde::{Deserialize, Serialize};
286///
287/// #[derive(Deserialize)]
288/// struct Payment {
289///     account: String,
290///     cents: u64,
291/// }
292///
293/// #[derive(Outgoing, Serialize)]
294/// #[outgoing(name = "ledger")]
295/// struct Entry {
296///     account: String,
297///     cents: u64,
298/// }
299///
300/// #[derive(OutSlot)]
301/// #[publishes(Entry)]
302/// struct Ledger;
303///
304/// /// Keys every entry by its account, so a `KeyShared` reader of the ledger sees one account's
305/// /// entries in order.
306/// #[subscriber(PulsarSubscription::new("payments", "bookkeeping"))]
307/// async fn book(
308///     payment: &Payment,
309///     Out(ledger): Out<impl Publisher<Options = PulsarPublishOptions>, Ledger>,
310/// ) -> HandlerOutcome {
311///     let entry = Entry {
312///         account: payment.account.clone(),
313///         cents: payment.cents,
314///     };
315///     match ledger.message(&entry).partition_key(&payment.account).publish().await {
316///         Ok(()) => HandlerOutcome::ack(),
317///         Err(_) => HandlerOutcome::retry(),
318///     }
319/// }
320/// #
321/// # #[ruststream::app]
322/// # fn app() -> impl App {
323/// #     RustStream::new(AppInfo::new("bookkeeping", "0.1.0")).with_broker(
324/// #         PulsarBroker::new("pulsar://localhost:6650"),
325/// #         |b| {
326/// #             b.include(book).out(Ledger, Publish).build();
327/// #         },
328/// #     )
329/// # }
330/// # }
331/// # fn main() {}
332/// ```
333pub trait PulsarPublishSteps {
334    /// Sends this one message under `key` as its partition key, which keyed routing places the
335    /// message by and `KeyShared` subscriptions order by.
336    ///
337    /// The key wins over a `partition-key` header the call site wrote itself; without either the
338    /// message is unkeyed.
339    ///
340    /// # Examples
341    ///
342    /// ```
343    /// # mod demo {
344    /// use ruststream_pulsar::prelude::*;
345    /// use serde::{Deserialize, Serialize};
346    ///
347    /// #[derive(Deserialize, Outgoing, Serialize)]
348    /// struct Click {
349    ///     session: String,
350    ///     page: String,
351    /// }
352    ///
353    /// #[derive(OutSlot)]
354    /// #[publishes(Click)]
355    /// struct Sessions;
356    ///
357    /// /// Re-keys raw clicks by session, so one session's clicks land on one partition.
358    /// #[subscriber(PulsarSubscription::new("clicks", "sessionizer"))]
359    /// async fn sessionize(
360    ///     click: &Click,
361    ///     Out(sessions): Out<impl Publisher<Options = PulsarPublishOptions>, Sessions>,
362    /// ) -> HandlerOutcome {
363    ///     let sent = sessions
364    ///         .message(click)
365    ///         .to("clicks-by-session")
366    ///         .partition_key(&click.session)
367    ///         .publish()
368    ///         .await;
369    ///     if sent.is_err() {
370    ///         return HandlerOutcome::retry();
371    ///     }
372    ///     HandlerOutcome::ack()
373    /// }
374    /// #
375    /// # #[ruststream::app]
376    /// # fn app() -> impl App {
377    /// #     RustStream::new(AppInfo::new("sessions", "0.1.0")).with_broker(
378    /// #         PulsarBroker::new("pulsar://localhost:6650"),
379    /// #         |b| {
380    /// #             b.include(sessionize).out(Sessions, Publish).build();
381    /// #         },
382    /// #     )
383    /// # }
384    /// # }
385    /// # fn main() {}
386    /// ```
387    #[must_use]
388    fn partition_key(self, key: impl Into<String>) -> Self;
389}
390
391impl<Sink, Body, Enc, Hdrs, Dest> PulsarPublishSteps for PublishBuilder<Sink, Body, Enc, Hdrs, Dest>
392where
393    Sink: PublishSink<Options = PulsarPublishOptions>,
394{
395    fn partition_key(mut self, key: impl Into<String>) -> Self {
396        self.options_mut()
397            .get_or_insert_with(PulsarPublishOptions::default)
398            .partition_key = Some(key.into());
399        self
400    }
401}
402
403/// The publish policy for [`PulsarPublisher`]: pure declaration, constructible anywhere,
404/// paired with the connected broker by the runtime after `connect`.
405///
406/// In the generated `AsyncAPI` document it describes the channel it publishes to: the
407/// specification's `pulsar` channel binding is a namespace and a persistence, and both live in
408/// the destination's topic name, which the runtime hands the policy. Its operation and message
409/// objects are empty, and a partition key belongs to one message rather than to the policy.
410/// Replies go to the destination the reply type or the mount site declares, the channel the
411/// document reports for them.
412///
413/// # Examples
414///
415/// A routes file that does not import this crate's prelude names the policy by this name:
416///
417/// ```
418/// # mod demo {
419/// use ruststream::prelude::*;
420/// use ruststream_pulsar::{PulsarBroker, PulsarPublish, PulsarSubscription};
421/// use serde::{Deserialize, Serialize};
422///
423/// #[derive(Deserialize)]
424/// struct Order {
425///     id: u64,
426/// }
427///
428/// #[derive(Outgoing, Serialize)]
429/// #[outgoing(name = "receipts")]
430/// struct Receipt {
431///     id: u64,
432/// }
433///
434/// #[subscriber(PulsarSubscription::new("orders", "workers"), reply)]
435/// async fn confirm(order: &Order) -> Receipt {
436///     Receipt { id: order.id }
437/// }
438///
439/// #[ruststream::app]
440/// fn app() -> impl App {
441///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
442///         PulsarBroker::new("pulsar://localhost:6650"),
443///         |b| {
444///             b.include(confirm).out_reply(PulsarPublish);
445///         },
446///     )
447/// }
448/// # }
449/// # fn main() {}
450/// ```
451#[derive(Debug, Clone, Copy, Default)]
452#[must_use]
453pub struct PulsarPublish;
454
455#[cfg(feature = "asyncapi")]
456impl PulsarPublish {
457    /// The `pulsar` channel binding of the destination the runtime resolved for this position:
458    /// the reply type's own name or the `reply("dest")` clause, a slot entry's name, the
459    /// `dead_letter("dlq")` declaration.
460    ///
461    /// A destination Pulsar would refuse as a topic name reports nothing rather than a guess,
462    /// the way an unserializable binding body does: a document describes the deployment or it
463    /// stays silent about it.
464    fn describe_channel(channel: &str) -> Bindings {
465        PulsarTopic::parse(channel).map_or_else(|_| Bindings::new(), |topic| topic_channel(&topic))
466    }
467}
468
469impl PublishPolicy<ConnectedPulsarBroker> for PulsarPublish {
470    type Live = PulsarPublisher;
471
472    fn pair(
473        self,
474        connected: &ConnectedPulsarBroker,
475    ) -> impl Future<Output = Result<Self::Live, PairError>> {
476        ready(Ok(connected.publisher()))
477    }
478
479    #[cfg(feature = "asyncapi")]
480    fn channel_bindings(&self, channel: &str) -> Bindings {
481        Self::describe_channel(channel)
482    }
483}
484
485#[cfg(all(test, feature = "testing"))]
486mod tests {
487    use ruststream::runtime::PublishExt;
488    use ruststream::testing::{InProcess, TestableBroker};
489    use ruststream::{HeaderMap, Outgoing, Serialized};
490    use serde::Serialize;
491
492    use super::PulsarPublishSteps;
493    use crate::{ConnectedPulsarBroker, PARTITION_KEY_HEADER, PulsarBroker};
494
495    /// The payload these tests carry: what they assert on is the key the publish carried, so the
496    /// body is deliberately opaque bytes rather than a model.
497    #[derive(Outgoing, Serialized)]
498    struct Record(&'static [u8]);
499
500    #[derive(Serialize)]
501    struct OrderMeta {
502        tenant: &'static str,
503    }
504
505    #[derive(Outgoing, Serialize)]
506    #[outgoing(name = "orders.done", headers = OrderMeta)]
507    struct OrderDone {
508        id: u64,
509    }
510
511    async fn connected() -> ConnectedPulsarBroker {
512        PulsarBroker::new("pulsar://localhost:6650")
513            .connect_in_process()
514            .await
515            .expect("the broker connects in process")
516    }
517
518    /// The step wins over the portable spelling of the same value, so a call site that sets both
519    /// gets the one it wrote last in the chain rather than a silent merge.
520    #[tokio::test]
521    async fn the_step_wins_over_a_call_site_header() {
522        let broker = connected().await;
523        let mut headers = HeaderMap::new();
524        headers.insert(PARTITION_KEY_HEADER, "user-7");
525        broker
526            .publisher()
527            .message(&Record(b"{}"))
528            .with_headers(headers)
529            .to("orders")
530            .partition_key("user-42")
531            .publish()
532            .await
533            .expect("publish succeeds");
534
535        let sent = broker.published("orders");
536        assert_eq!(
537            sent[0].headers().get(PARTITION_KEY_HEADER),
538            Some(b"user-42".as_slice())
539        );
540    }
541
542    /// The case a header cannot reach: the publish's headers position is taken by a declared
543    /// contract, and the key is a setting rather than one more entry in that map.
544    #[tokio::test]
545    async fn the_step_composes_with_a_declared_header_contract() {
546        let broker = connected().await;
547        broker
548            .publisher()
549            .message(&OrderDone { id: 1 })
550            .with_headers(&OrderMeta { tenant: "acme" })
551            .partition_key("user-42")
552            .publish()
553            .await
554            .expect("publish succeeds");
555
556        let sent = broker.published("orders.done");
557        assert_eq!(sent[0].headers().get_str("tenant"), Some("acme"));
558        assert_eq!(
559            sent[0].headers().get(PARTITION_KEY_HEADER),
560            Some(b"user-42".as_slice())
561        );
562    }
563}