ruststream_gcp_pubsub/publisher.rs
1//! [`PubSubPublisher`], its [`PubSubPublish`] policy, the [`PubSubPublishOptions`] a single
2//! message may differ by, and the [`PubSubOrdering`] step that names one.
3
4// Without the `testing` feature a link has one variant, so a `match` on it has a single arm; the
5// match stays 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::fmt;
12use std::future::{Future, ready};
13use std::sync::Arc;
14
15use bytes::BytesMut;
16use google_cloud_pubsub::client::Publisher as GcpPublisher;
17#[cfg(feature = "asyncapi")]
18use ruststream::asyncapi::{Binding, Bindings};
19use ruststream::runtime::{PublishBuilder, PublishSink};
20use ruststream::{HeaderMap, OutgoingMessage, PairError, PublishPolicy, Publisher, Take};
21#[cfg(feature = "asyncapi")]
22use serde::Serialize;
23use tokio::runtime::Handle;
24
25use crate::broker::{ConnectedPubSubBroker, Core, CoreCell, Link};
26use crate::error::{PubSubError, box_err};
27use crate::message::{PARTITION_KEY_HEADER, header_text, to_gcp_message};
28use crate::topic_publishers::FirstTopic;
29
30/// The settings one Pub/Sub message may differ from the next by.
31///
32/// Pub/Sub gives a message exactly one such field, its ordering key, so that is the whole type.
33/// The field is optional, because a publish carries only what its call site adjusted: what a call
34/// leaves unset keeps what the [`PubSubPublish`] policy fixed at the mount site.
35///
36/// A handler body that names the [`ordering_key`](PubSubOrdering::ordering_key) step bounds its
37/// slot on this type, and a test reads the value back with
38/// `tb.out::<Marker>().with_options(..)`.
39///
40/// # Examples
41///
42/// ```
43/// # #[cfg(feature = "testing")]
44/// # mod demo {
45/// use std::error::Error;
46///
47/// use ruststream::testing::TestApp;
48/// use ruststream_gcp_pubsub::prelude::*;
49/// use serde::{Deserialize, Serialize};
50///
51/// #[derive(Debug, Deserialize, Serialize, Outgoing)]
52/// struct Order {
53/// id: u64,
54/// }
55///
56/// #[subscriber("orders-workers")]
57/// async fn confirm(
58/// order: &Order,
59/// Out(out): Out<impl Publisher<Options = PubSubPublishOptions>>,
60/// ) -> HandlerOutcome {
61/// let sent = out
62/// .message(order)
63/// .to("confirmations")
64/// .ordering_key(format!("order-{}", order.id))
65/// .publish()
66/// .await;
67/// if sent.is_err() {
68/// return HandlerOutcome::retry();
69/// }
70/// HandlerOutcome::ack()
71/// }
72///
73/// /// The app `main` runs, and the one the test hands the harness.
74/// pub fn app() -> impl App {
75/// RustStream::new(AppInfo::new("orders", "0.1.0"))
76/// .with_broker(PubSubBroker::new("my-project"), |b| {
77/// b.include(confirm).out(DefaultSlot, Publish::default()).build();
78/// })
79/// }
80///
81/// pub async fn each_confirmation_carries_its_orders_key() -> Result<(), Box<dyn Error>> {
82/// let tb = TestApp::start(app()).await?;
83///
84/// // A subscription the service only names is attached to the topic of its own name.
85/// tb.broker::<PubSubBroker>()
86/// .message(&Order { id: 42 })
87/// .to("orders-workers")
88/// .publish()
89/// .await?;
90/// tb.settle().await?;
91///
92/// tb.out::<DefaultSlot>()
93/// .assert_called(1)
94/// .with_options(&PubSubPublishOptions {
95/// ordering_key: Some("order-42".to_owned()),
96/// });
97/// tb.shutdown().await?;
98/// Ok(())
99/// }
100/// # }
101/// # #[cfg(feature = "testing")]
102/// # #[tokio::main]
103/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
104/// # demo::each_confirmation_carries_its_orders_key().await
105/// # }
106/// # #[cfg(not(feature = "testing"))]
107/// # fn main() {}
108/// ```
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct PubSubPublishOptions {
111 /// The message's ordering key. Messages sharing a key reach one subscriber in publish
112 /// order, on a subscription that enables message ordering; `None` leaves the publish
113 /// unordered unless the policy fixed a key.
114 pub ordering_key: Option<String>,
115}
116
117/// Publishes messages to Pub/Sub topics, one client publisher per topic, created lazily and
118/// shared through the broker core (so `shutdown` can flush buffered batches).
119///
120/// A publish to a topic whose client publisher exists takes no lock held across an await and
121/// allocates nothing for the topic's name.
122///
123/// The destination name is the topic id (short or full resource name). Message attributes carry
124/// headers directly, and the ordering key reaches the client as the message's own field.
125/// Buildable before `connect` and usable until `shutdown`; afterwards every publish reports
126/// [`PubSubError::NotConnected`] instead of silently succeeding.
127#[derive(Clone)]
128pub struct PubSubPublisher {
129 cell: CoreCell,
130 /// The key the mount site fixed for this handle, applied to every publish that names none.
131 default_ordering_key: Option<Arc<str>>,
132 /// The first topic this handle published to, shared by the handle's clones.
133 first_topic: Arc<FirstTopic>,
134}
135
136impl fmt::Debug for PubSubPublisher {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 f.debug_struct("PubSubPublisher")
139 .field("default_ordering_key", &self.default_ordering_key)
140 .finish_non_exhaustive()
141 }
142}
143
144impl PubSubPublisher {
145 pub(crate) fn new(cell: CoreCell) -> Self {
146 Self {
147 cell,
148 default_ordering_key: None,
149 first_topic: Arc::default(),
150 }
151 }
152
153 /// The handle a policy pairs into: the same connection cell, carrying the policy's key.
154 pub(crate) fn with_default_ordering_key(mut self, key: Option<Arc<str>>) -> Self {
155 self.default_ordering_key = key;
156 self
157 }
158
159 /// What this handle speaks over, once `connect` has filled the shared cell.
160 fn link(&self) -> Result<&Link, PubSubError> {
161 self.cell.get().ok_or(PubSubError::NotConnected)
162 }
163
164 /// Hands `use_it` the client publisher of `topic`, made on the connection's first publish
165 /// to the topic and shared by every handle of the connection.
166 fn with_client<Out>(
167 &self,
168 core: &Core,
169 topic: &str,
170 use_it: impl FnOnce(&GcpPublisher) -> Out,
171 ) -> Out {
172 if let Some(publisher) = self.first_topic.get(topic) {
173 return use_it(publisher);
174 }
175 // Sync and infallible off the connected BasePublisher; the network work happened in
176 // connect. The client starts the topic's batching worker on the runtime `build` runs in,
177 // and the first publish may come from a runtime that stops right after (a handler on a
178 // dedicated thread), so the worker is started on the runtime the broker connected on.
179 let build = || {
180 let connect_runtime = core.runtime_slot().runtime();
181 let _entered = connect_runtime.as_ref().map(Handle::enter);
182 core.base_publisher
183 .publisher(core.topic_name(topic))
184 .build()
185 };
186 core.publishers
187 .with(core.topic_key(topic), build, |publisher| {
188 self.first_topic.remember(topic, publisher);
189 use_it(publisher)
190 })
191 }
192}
193
194/// The ordering key one publish carries, resolved over the three places it can come from.
195///
196/// The native spelling wins: the message's own settings, written by the
197/// [`ordering_key`](PubSubOrdering::ordering_key) step at a call site or by a publish transform on
198/// the position the message leaves through. Next comes the framework's `partition-key` header,
199/// which is the portable spelling of the same key and keeps a service that names no broker able to
200/// order its messages. What neither named is what the policy fixed for the mount site.
201///
202/// # Errors
203///
204/// Returns the reason when the key comes from a header that is not UTF-8: an ordering key is text,
205/// and a rewritten one would order the message under a key nobody named.
206pub(crate) fn resolve_ordering_key<'a>(
207 headers: &'a HeaderMap,
208 options: Option<&'a PubSubPublishOptions>,
209 policy: Option<&'a str>,
210) -> Result<Option<&'a str>, String> {
211 if let Some(key) = options.and_then(|options| options.ordering_key.as_deref()) {
212 return Ok(Some(key));
213 }
214 if let Some(value) = headers.get(PARTITION_KEY_HEADER) {
215 return header_text(PARTITION_KEY_HEADER, value).map(Some);
216 }
217 Ok(policy)
218}
219
220impl Publisher for PubSubPublisher {
221 /// The client keeps the payload: a Pub/Sub message's data is a `Bytes`, so the buffer the
222 /// framework wrote becomes it.
223 type Payload = Take;
224
225 type Error = PubSubError;
226 type Options = PubSubPublishOptions;
227
228 async fn publish(
229 &self,
230 msg: OutgoingMessage<'_, BytesMut>,
231 options: Option<&Self::Options>,
232 ) -> Result<(), Self::Error> {
233 let core = match self.link()? {
234 Link::Service(core) => core,
235 #[cfg(feature = "testing")]
236 Link::InProcess(project) => {
237 let (name, payload, headers) = msg.into_parts();
238 let refused = |reason: String| PubSubError::Publish {
239 topic: project.topic_path(name),
240 source: reason.into(),
241 };
242 let key =
243 resolve_ordering_key(&headers, options, self.default_ordering_key.as_deref())
244 .map_err(refused)?;
245 let message = to_gcp_message(payload, &headers, key).map_err(refused)?;
246 return project.publish(name, &message);
247 }
248 };
249 core.ensure_open()?;
250 let name = msg.name();
251 let (_, payload, headers) = msg.into_parts();
252 let refused = |reason: String| PubSubError::Publish {
253 topic: core.topic_name(name),
254 source: reason.into(),
255 };
256 let key = resolve_ordering_key(&headers, options, self.default_ordering_key.as_deref())
257 .map_err(refused)?;
258 let message = to_gcp_message(payload, &headers, key).map_err(refused)?;
259 // The client queues the message and hands back a future that owns its answer, so the
260 // client publisher is borrowed for the call alone.
261 let sent = self.with_client(core, name, |publisher| publisher.publish(message));
262 match sent.await {
263 Ok(_message_id) => Ok(()),
264 Err(err) => {
265 // An error on an ordered key pauses the key; resume so the pause cannot wedge
266 // every later publish on this key, and let the caller see this failure.
267 if let Some(key) = key {
268 self.with_client(core, name, |publisher| {
269 publisher.resume_publish(key.to_owned());
270 });
271 }
272 Err(PubSubError::Publish {
273 topic: core.topic_name(name),
274 source: box_err(err),
275 })
276 }
277 }
278 }
279}
280
281/// Names the ordering key of one publish.
282///
283/// The step sits on the framework's publish builder, between `message(..)` and `publish()`, so
284/// the publish it finishes is still the mount site's: the codec that entry named, its transforms
285/// and its slot attribution all hold. The key reaches the client as the message's own ordering
286/// key and never as an attribute.
287///
288/// It resolves only on a builder over a Pub/Sub publisher, which is what the bound on the sink's
289/// options type buys: on any other broker's builder the method does not exist.
290///
291/// A handler body that names it imports this crate's prelude and bounds its slot
292/// `Out<impl Publisher<Options = PubSubPublishOptions>, Marker>`. Where a whole slot orders under
293/// one key, the mount site says so once instead - [`PubSubPublish::ordering_key`].
294///
295/// # Examples
296///
297/// ```
298/// # mod demo {
299/// use ruststream_gcp_pubsub::prelude::*;
300/// use serde::{Deserialize, Serialize};
301///
302/// #[derive(Debug, Deserialize, Serialize, Outgoing)]
303/// struct Shipment {
304/// order_id: u64,
305/// warehouse: String,
306/// }
307///
308/// /// Every update of one order travels under that order's key, so a consumer of
309/// /// `shipment-updates` sees them in the order they were sent.
310/// #[subscriber("shipments-workers")]
311/// async fn track(
312/// shipment: &Shipment,
313/// Out(out): Out<impl Publisher<Options = PubSubPublishOptions>>,
314/// ) -> HandlerOutcome {
315/// let sent = out
316/// .message(shipment)
317/// .to("shipment-updates")
318/// .ordering_key(format!("order-{}", shipment.order_id))
319/// .publish()
320/// .await;
321/// match sent {
322/// Ok(_) => HandlerOutcome::ack(),
323/// Err(_) => HandlerOutcome::retry(),
324/// }
325/// }
326///
327/// #[ruststream::app]
328/// fn app() -> impl App {
329/// RustStream::new(AppInfo::new("shipments", "0.1.0"))
330/// .with_broker(PubSubBroker::new("my-project"), |b| {
331/// b.include(track).out(DefaultSlot, Publish::default()).build();
332/// })
333/// }
334/// # }
335/// # fn main() {}
336/// ```
337pub trait PubSubOrdering {
338 /// Sends this one message under `key`, whatever the mount site's default is.
339 ///
340 /// Pass a `&str` or an owned `String`; the key is copied into the message's settings.
341 #[must_use]
342 fn ordering_key(self, key: impl Into<String>) -> Self;
343}
344
345impl<Sink, Body, Enc, Hdrs, Dest> PubSubOrdering for PublishBuilder<Sink, Body, Enc, Hdrs, Dest>
346where
347 Sink: PublishSink<Options = PubSubPublishOptions>,
348{
349 fn ordering_key(mut self, key: impl Into<String>) -> Self {
350 self.options_mut()
351 .get_or_insert_with(PubSubPublishOptions::default)
352 .ordering_key = Some(key.into());
353 self
354 }
355}
356
357/// The publish policy for [`PubSubPublisher`]: pure declaration, constructible anywhere, paired
358/// with the connected broker by the runtime after `connect`.
359///
360/// It carries the defaults of the per-message settings, which on Pub/Sub means one: the ordering
361/// key every message through this mount site is sent under.
362///
363/// # Examples
364///
365/// ```
366/// # mod demo {
367/// use ruststream_gcp_pubsub::prelude::*;
368/// use serde::{Deserialize, Serialize};
369///
370/// #[derive(Debug, Deserialize)]
371/// struct Order {
372/// id: u64,
373/// }
374///
375/// #[derive(Serialize, Outgoing)]
376/// #[outgoing(name = "receipts")]
377/// struct Receipt {
378/// order_id: u64,
379/// }
380///
381/// #[subscriber("orders-receipts", reply)]
382/// async fn receipt(order: &Order) -> Receipt {
383/// Receipt { order_id: order.id }
384/// }
385///
386/// #[ruststream::app]
387/// fn app() -> impl App {
388/// RustStream::new(AppInfo::new("orders", "0.1.0"))
389/// .with_broker(PubSubBroker::new("my-project"), |b| {
390/// // A reply has no call site, so every receipt is ordered under the policy's key.
391/// b.include(receipt).out_reply(Publish::default().ordering_key("receipts"));
392/// })
393/// }
394/// # }
395/// # fn main() {}
396/// ```
397#[derive(Debug, Clone, Default, PartialEq, Eq)]
398#[must_use]
399pub struct PubSubPublish {
400 ordering_key: Option<String>,
401}
402
403impl PubSubPublish {
404 /// Orders every message this mount site sends under `key`.
405 ///
406 /// Reach for it where a whole slot belongs to one entity - one order's events, one device's
407 /// telemetry. A key that differs per message is the call's to name, with the
408 /// [`ordering_key`](PubSubOrdering::ordering_key) step.
409 pub fn ordering_key(mut self, key: impl Into<String>) -> Self {
410 self.ordering_key = Some(key.into());
411 self
412 }
413
414 /// The key this policy hands its live publisher, in the form the publisher keeps it.
415 pub(crate) fn default_key(&self) -> Option<Arc<str>> {
416 self.ordering_key.as_deref().map(Arc::from)
417 }
418
419 /// What a document can say about the messages this mount site sends.
420 ///
421 /// Only the ordering key, and only where the mount site fixed one: everything else the
422 /// `googlepubsub` binding describes belongs to the topic resource (its labels, its
423 /// retention, its storage policy, its schema), which no publish policy configures and no
424 /// destination name tells. A key named per message is named at a call site, and a call site
425 /// is not in the document.
426 #[cfg(feature = "asyncapi")]
427 pub(crate) fn message_binding(&self) -> Bindings {
428 let Some(ordering_key) = self.ordering_key.as_deref() else {
429 return Bindings::new();
430 };
431 let body = PubSubMessageBinding { ordering_key };
432 // A binding that fails to build is a binding the document goes without: a broker never
433 // holds up a service over a description of itself.
434 Binding::new("googlepubsub", BINDING_VERSION, &body)
435 .map_or_else(|_| Bindings::new(), |binding| Bindings::new().with(binding))
436 }
437}
438
439impl PublishPolicy<ConnectedPubSubBroker> for PubSubPublish {
440 type Live = PubSubPublisher;
441
442 fn pair(
443 self,
444 connected: &ConnectedPubSubBroker,
445 ) -> impl Future<Output = Result<Self::Live, PairError>> {
446 let key = self.default_key();
447 ready(Ok(connected.publisher().with_default_ordering_key(key)))
448 }
449
450 /// The ordering key every message through this mount site is sent under, which is the one
451 /// field of the `googlepubsub` binding this crate can state without a connection.
452 ///
453 /// The destination the runtime resolved is the topic this mount site publishes to, and the
454 /// document already reports it as the channel's address; the channel half of the binding
455 /// carries topic configuration alone, so naming the topic here adds nothing and the
456 /// parameter goes unread.
457 #[cfg(feature = "asyncapi")]
458 fn message_bindings(&self, _channel: &str) -> Bindings {
459 self.message_binding()
460 }
461}
462
463/// The version of the `googlepubsub` binding this crate writes.
464#[cfg(feature = "asyncapi")]
465const BINDING_VERSION: &str = "0.2.0";
466
467/// The message half of the `googlepubsub` binding: what a publish carries beyond its payload and
468/// its attributes.
469#[cfg(feature = "asyncapi")]
470#[derive(Serialize)]
471struct PubSubMessageBinding<'a> {
472 #[serde(rename = "orderingKey")]
473 ordering_key: &'a str,
474}
475
476#[cfg(test)]
477mod tests {
478 use std::sync::atomic::{AtomicUsize, Ordering};
479
480 use bytes::Bytes;
481 use ruststream::{Broker, HeaderMap};
482 use tokio::sync::Barrier;
483
484 use super::*;
485 use crate::broker::PubSubBroker;
486
487 /// The connection's core. The client dials lazily, so an address nothing listens on connects.
488 fn core_of(publisher: &PubSubPublisher) -> &Core {
489 match publisher.link().expect("the broker is connected") {
490 Link::Service(core) => core,
491 #[cfg(feature = "testing")]
492 Link::InProcess(_) => unreachable!("the broker connected to the service"),
493 }
494 }
495
496 /// Two handles naming one topic two ways share its client publisher, so messages under one
497 /// ordering key leave through one batcher and keep their order.
498 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
499 async fn the_short_and_the_full_name_of_a_topic_share_one_client_publisher() {
500 let connected = PubSubBroker::new("shop")
501 .emulator("127.0.0.1:9")
502 .connect()
503 .await
504 .expect("the client dials lazily");
505 let (short, full) = (connected.publisher(), connected.publisher());
506 let core = core_of(&short);
507
508 short.with_client(core, "orders", |_| ());
509 full.with_client(core, "projects/shop/topics/orders", |_| ());
510 short.with_client(core, "projects/shop/topics/orders", |_| ());
511
512 assert_eq!(core.publishers.len(), 1);
513 }
514
515 /// First publishes racing on many threads, each handle to a topic of its own and all of them
516 /// to one shared topic, build one client publisher per topic: a race never builds a second
517 /// one, which would split one ordering key across two batchers.
518 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
519 async fn racing_first_publishes_build_one_client_publisher_per_topic() {
520 const HANDLES: usize = 16;
521 let connected = PubSubBroker::new("shop")
522 .emulator("127.0.0.1:9")
523 .connect()
524 .await
525 .expect("the client dials lazily");
526 let builds = Arc::new(AtomicUsize::new(0));
527 let start = Arc::new(Barrier::new(HANDLES));
528 let racers: Vec<_> = (0..HANDLES)
529 .map(|index| {
530 let publisher = connected.publisher();
531 let (builds, start) = (Arc::clone(&builds), Arc::clone(&start));
532 tokio::spawn(async move {
533 let core = core_of(&publisher);
534 let build = |topic: &str| {
535 builds.fetch_add(1, Ordering::Relaxed);
536 core.base_publisher
537 .publisher(core.topic_name(topic))
538 .build()
539 };
540 start.wait().await;
541 for topic in ["shared".to_owned(), format!("own-{index}")] {
542 core.publishers.with(&topic, || build(&topic), |_| ());
543 }
544 })
545 })
546 .collect();
547 for racer in racers {
548 racer.await.expect("the racer finishes");
549 }
550
551 assert_eq!(builds.load(Ordering::Relaxed), HANDLES + 1);
552 assert_eq!(
553 core_of(&connected.publisher()).publishers.len(),
554 HANDLES + 1
555 );
556 }
557
558 fn keyed_header(value: &str) -> HeaderMap {
559 let mut headers = HeaderMap::new();
560 headers.insert(PARTITION_KEY_HEADER, value.to_owned());
561 headers
562 }
563
564 /// The message's own settings are the native spelling, so they win over both the portable
565 /// header and the key the mount site fixed for every message.
566 #[test]
567 fn the_messages_own_setting_wins_over_the_header_and_the_policy() {
568 let options = PubSubPublishOptions {
569 ordering_key: Some("step".to_owned()),
570 };
571
572 let headers = keyed_header("header");
573
574 let key = resolve_ordering_key(&headers, Some(&options), Some("policy"));
575 assert_eq!(key, Ok(Some("step")));
576 }
577
578 /// A handler that names no broker still orders its messages: the framework's header is the
579 /// portable spelling of the same key, and it is a call site too.
580 #[test]
581 fn the_partition_key_header_orders_a_publish() {
582 let headers = keyed_header("header");
583
584 let key = resolve_ordering_key(&headers, None, Some("policy"));
585 assert_eq!(key, Ok(Some("header")));
586 }
587
588 /// What no call site named is what the mount site fixed.
589 #[test]
590 fn a_publish_that_names_nothing_takes_the_policys_key() {
591 let headers = HeaderMap::new();
592
593 let key = resolve_ordering_key(&headers, None, Some("policy"));
594 assert_eq!(key, Ok(Some("policy")));
595 }
596
597 /// Nothing anywhere means an unordered publish, which is Pub/Sub's own default.
598 #[test]
599 fn a_publish_with_no_key_anywhere_is_unordered() {
600 let headers = HeaderMap::new();
601
602 assert_eq!(resolve_ordering_key(&headers, None, None), Ok(None));
603 }
604
605 /// An ordering key is text, so a binary `partition-key` header is refused rather than
606 /// ordering the message under a rewritten key.
607 #[test]
608 fn a_partition_key_header_that_is_not_text_is_refused() {
609 let mut headers = HeaderMap::new();
610 headers.insert(PARTITION_KEY_HEADER, Bytes::from_static(&[0x80, 0xff]));
611
612 let refused = resolve_ordering_key(&headers, None, Some("policy"))
613 .expect_err("a non-UTF-8 key has no ordering-key form");
614 assert!(refused.contains(PARTITION_KEY_HEADER), "{refused}");
615 }
616}