#![cfg_attr(
not(feature = "testing"),
allow(clippy::infallible_destructuring_match)
)]
use std::fmt;
use std::future::{Future, ready};
use std::sync::Arc;
use bytes::BytesMut;
use google_cloud_pubsub::client::Publisher as GcpPublisher;
#[cfg(feature = "asyncapi")]
use ruststream::asyncapi::{Binding, Bindings};
use ruststream::runtime::{PublishBuilder, PublishSink};
use ruststream::{HeaderMap, OutgoingMessage, PairError, PublishPolicy, Publisher, Take};
#[cfg(feature = "asyncapi")]
use serde::Serialize;
use tokio::runtime::Handle;
use crate::broker::{ConnectedPubSubBroker, Core, CoreCell, Link};
use crate::error::{PubSubError, box_err};
use crate::message::{PARTITION_KEY_HEADER, header_text, to_gcp_message};
use crate::topic_publishers::FirstTopic;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PubSubPublishOptions {
pub ordering_key: Option<String>,
}
#[derive(Clone)]
pub struct PubSubPublisher {
cell: CoreCell,
default_ordering_key: Option<Arc<str>>,
first_topic: Arc<FirstTopic>,
}
impl fmt::Debug for PubSubPublisher {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PubSubPublisher")
.field("default_ordering_key", &self.default_ordering_key)
.finish_non_exhaustive()
}
}
impl PubSubPublisher {
pub(crate) fn new(cell: CoreCell) -> Self {
Self {
cell,
default_ordering_key: None,
first_topic: Arc::default(),
}
}
pub(crate) fn with_default_ordering_key(mut self, key: Option<Arc<str>>) -> Self {
self.default_ordering_key = key;
self
}
fn link(&self) -> Result<&Link, PubSubError> {
self.cell.get().ok_or(PubSubError::NotConnected)
}
fn with_client<Out>(
&self,
core: &Core,
topic: &str,
use_it: impl FnOnce(&GcpPublisher) -> Out,
) -> Out {
if let Some(publisher) = self.first_topic.get(topic) {
return use_it(publisher);
}
let build = || {
let connect_runtime = core.runtime_slot().runtime();
let _entered = connect_runtime.as_ref().map(Handle::enter);
core.base_publisher
.publisher(core.topic_name(topic))
.build()
};
core.publishers
.with(core.topic_key(topic), build, |publisher| {
self.first_topic.remember(topic, publisher);
use_it(publisher)
})
}
}
pub(crate) fn resolve_ordering_key<'a>(
headers: &'a HeaderMap,
options: Option<&'a PubSubPublishOptions>,
policy: Option<&'a str>,
) -> Result<Option<&'a str>, String> {
if let Some(key) = options.and_then(|options| options.ordering_key.as_deref()) {
return Ok(Some(key));
}
if let Some(value) = headers.get(PARTITION_KEY_HEADER) {
return header_text(PARTITION_KEY_HEADER, value).map(Some);
}
Ok(policy)
}
impl Publisher for PubSubPublisher {
type Payload = Take;
type Error = PubSubError;
type Options = PubSubPublishOptions;
async fn publish(
&self,
msg: OutgoingMessage<'_, BytesMut>,
options: Option<&Self::Options>,
) -> Result<(), Self::Error> {
let core = match self.link()? {
Link::Service(core) => core,
#[cfg(feature = "testing")]
Link::InProcess(project) => {
let (name, payload, headers) = msg.into_parts();
let refused = |reason: String| PubSubError::Publish {
topic: project.topic_path(name),
source: reason.into(),
};
let key =
resolve_ordering_key(&headers, options, self.default_ordering_key.as_deref())
.map_err(refused)?;
let message = to_gcp_message(payload, &headers, key).map_err(refused)?;
return project.publish(name, &message);
}
};
core.ensure_open()?;
let name = msg.name();
let (_, payload, headers) = msg.into_parts();
let refused = |reason: String| PubSubError::Publish {
topic: core.topic_name(name),
source: reason.into(),
};
let key = resolve_ordering_key(&headers, options, self.default_ordering_key.as_deref())
.map_err(refused)?;
let message = to_gcp_message(payload, &headers, key).map_err(refused)?;
let sent = self.with_client(core, name, |publisher| publisher.publish(message));
match sent.await {
Ok(_message_id) => Ok(()),
Err(err) => {
if let Some(key) = key {
self.with_client(core, name, |publisher| {
publisher.resume_publish(key.to_owned());
});
}
Err(PubSubError::Publish {
topic: core.topic_name(name),
source: box_err(err),
})
}
}
}
}
pub trait PubSubOrdering {
#[must_use]
fn ordering_key(self, key: impl Into<String>) -> Self;
}
impl<Sink, Body, Enc, Hdrs, Dest> PubSubOrdering for PublishBuilder<Sink, Body, Enc, Hdrs, Dest>
where
Sink: PublishSink<Options = PubSubPublishOptions>,
{
fn ordering_key(mut self, key: impl Into<String>) -> Self {
self.options_mut()
.get_or_insert_with(PubSubPublishOptions::default)
.ordering_key = Some(key.into());
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[must_use]
pub struct PubSubPublish {
ordering_key: Option<String>,
}
impl PubSubPublish {
pub fn ordering_key(mut self, key: impl Into<String>) -> Self {
self.ordering_key = Some(key.into());
self
}
pub(crate) fn default_key(&self) -> Option<Arc<str>> {
self.ordering_key.as_deref().map(Arc::from)
}
#[cfg(feature = "asyncapi")]
pub(crate) fn message_binding(&self) -> Bindings {
let Some(ordering_key) = self.ordering_key.as_deref() else {
return Bindings::new();
};
let body = PubSubMessageBinding { ordering_key };
Binding::new("googlepubsub", BINDING_VERSION, &body)
.map_or_else(|_| Bindings::new(), |binding| Bindings::new().with(binding))
}
}
impl PublishPolicy<ConnectedPubSubBroker> for PubSubPublish {
type Live = PubSubPublisher;
fn pair(
self,
connected: &ConnectedPubSubBroker,
) -> impl Future<Output = Result<Self::Live, PairError>> {
let key = self.default_key();
ready(Ok(connected.publisher().with_default_ordering_key(key)))
}
#[cfg(feature = "asyncapi")]
fn message_bindings(&self, _channel: &str) -> Bindings {
self.message_binding()
}
}
#[cfg(feature = "asyncapi")]
const BINDING_VERSION: &str = "0.2.0";
#[cfg(feature = "asyncapi")]
#[derive(Serialize)]
struct PubSubMessageBinding<'a> {
#[serde(rename = "orderingKey")]
ordering_key: &'a str,
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use bytes::Bytes;
use ruststream::{Broker, HeaderMap};
use tokio::sync::Barrier;
use super::*;
use crate::broker::PubSubBroker;
fn core_of(publisher: &PubSubPublisher) -> &Core {
match publisher.link().expect("the broker is connected") {
Link::Service(core) => core,
#[cfg(feature = "testing")]
Link::InProcess(_) => unreachable!("the broker connected to the service"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_short_and_the_full_name_of_a_topic_share_one_client_publisher() {
let connected = PubSubBroker::new("shop")
.emulator("127.0.0.1:9")
.connect()
.await
.expect("the client dials lazily");
let (short, full) = (connected.publisher(), connected.publisher());
let core = core_of(&short);
short.with_client(core, "orders", |_| ());
full.with_client(core, "projects/shop/topics/orders", |_| ());
short.with_client(core, "projects/shop/topics/orders", |_| ());
assert_eq!(core.publishers.len(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn racing_first_publishes_build_one_client_publisher_per_topic() {
const HANDLES: usize = 16;
let connected = PubSubBroker::new("shop")
.emulator("127.0.0.1:9")
.connect()
.await
.expect("the client dials lazily");
let builds = Arc::new(AtomicUsize::new(0));
let start = Arc::new(Barrier::new(HANDLES));
let racers: Vec<_> = (0..HANDLES)
.map(|index| {
let publisher = connected.publisher();
let (builds, start) = (Arc::clone(&builds), Arc::clone(&start));
tokio::spawn(async move {
let core = core_of(&publisher);
let build = |topic: &str| {
builds.fetch_add(1, Ordering::Relaxed);
core.base_publisher
.publisher(core.topic_name(topic))
.build()
};
start.wait().await;
for topic in ["shared".to_owned(), format!("own-{index}")] {
core.publishers.with(&topic, || build(&topic), |_| ());
}
})
})
.collect();
for racer in racers {
racer.await.expect("the racer finishes");
}
assert_eq!(builds.load(Ordering::Relaxed), HANDLES + 1);
assert_eq!(
core_of(&connected.publisher()).publishers.len(),
HANDLES + 1
);
}
fn keyed_header(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(PARTITION_KEY_HEADER, value.to_owned());
headers
}
#[test]
fn the_messages_own_setting_wins_over_the_header_and_the_policy() {
let options = PubSubPublishOptions {
ordering_key: Some("step".to_owned()),
};
let headers = keyed_header("header");
let key = resolve_ordering_key(&headers, Some(&options), Some("policy"));
assert_eq!(key, Ok(Some("step")));
}
#[test]
fn the_partition_key_header_orders_a_publish() {
let headers = keyed_header("header");
let key = resolve_ordering_key(&headers, None, Some("policy"));
assert_eq!(key, Ok(Some("header")));
}
#[test]
fn a_publish_that_names_nothing_takes_the_policys_key() {
let headers = HeaderMap::new();
let key = resolve_ordering_key(&headers, None, Some("policy"));
assert_eq!(key, Ok(Some("policy")));
}
#[test]
fn a_publish_with_no_key_anywhere_is_unordered() {
let headers = HeaderMap::new();
assert_eq!(resolve_ordering_key(&headers, None, None), Ok(None));
}
#[test]
fn a_partition_key_header_that_is_not_text_is_refused() {
let mut headers = HeaderMap::new();
headers.insert(PARTITION_KEY_HEADER, Bytes::from_static(&[0x80, 0xff]));
let refused = resolve_ordering_key(&headers, None, Some("policy"))
.expect_err("a non-UTF-8 key has no ordering-key form");
assert!(refused.contains(PARTITION_KEY_HEADER), "{refused}");
}
}