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}