Skip to main content

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}