#![cfg_attr(
not(feature = "testing"),
allow(clippy::infallible_destructuring_match)
)]
use std::collections::HashMap;
#[cfg(feature = "testing")]
use std::future::{Future, ready};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(feature = "testing")]
use std::sync::{Mutex, PoisonError};
#[cfg(feature = "testing")]
use bytes::BytesMut;
use google_cloud_auth::credentials::{Credentials, anonymous};
use google_cloud_pubsub::client::{
BasePublisher, Publisher, Subscriber, SubscriptionAdmin, TopicAdmin,
};
use google_cloud_pubsub::model::{DeadLetterPolicy, Subscription};
use google_cloud_wkt::FieldMask;
#[cfg(feature = "testing")]
use ruststream::testing::{Coordinator, InProcess, TestableBroker};
use ruststream::{
Broker, BrokerMoves, ConnectedBroker, DeclareRetryError, DefaultPublish, DescribeServer,
RetryDeclaration, ServerSpec, Subscribe,
};
#[cfg(feature = "testing")]
use ruststream::{OutgoingMessage, RawMessage};
use tokio::runtime::Handle;
use tokio::sync::OnceCell;
use crate::error::{PubSubError, box_err};
#[cfg(feature = "testing")]
use crate::in_process::{self, Project};
#[cfg(feature = "testing")]
use crate::message::to_gcp_message;
#[cfg(feature = "testing")]
use crate::publisher::resolve_ordering_key;
use crate::publisher::{PubSubPublish, PubSubPublisher};
use crate::runtime_slot::{RuntimeRegistration, RuntimeSlot};
use crate::subscriber::PubSubSubscriber;
use crate::subscription::{DeclaredRetries, GooglePubSub};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Resource {
Created,
Existing,
}
pub(crate) fn topic_path(project: &str, topic: &str) -> String {
if topic.starts_with("projects/") {
topic.to_owned()
} else {
format!("projects/{project}/topics/{topic}")
}
}
pub(crate) fn subscription_path(project: &str, subscription: &str) -> String {
if subscription.starts_with("projects/") {
subscription.to_owned()
} else {
format!("projects/{project}/subscriptions/{subscription}")
}
}
pub(crate) struct Core {
pub(crate) subscriber: Subscriber,
pub(crate) base_publisher: BasePublisher,
pub(crate) topic_admin: TopicAdmin,
pub(crate) subscription_admin: SubscriptionAdmin,
pub(crate) project: String,
pub(crate) closed: AtomicBool,
pub(crate) publishers: tokio::sync::Mutex<HashMap<String, Publisher>>,
pub(crate) declared_retries: DeclaredRetries,
runtime: RuntimeRegistration,
#[cfg(feature = "testing")]
attached: Mutex<HashMap<String, String>>,
}
impl Core {
pub(crate) fn ensure_open(&self) -> Result<(), PubSubError> {
if self.closed.load(Ordering::Acquire) {
return Err(PubSubError::NotConnected);
}
Ok(())
}
pub(crate) const fn runtime_slot(&self) -> RuntimeSlot {
self.runtime.slot()
}
pub(crate) fn topic_name(&self, topic: &str) -> String {
topic_path(&self.project, topic)
}
pub(crate) fn subscription_name(&self, subscription: &str) -> String {
subscription_path(&self.project, subscription)
}
async fn subscribe(
&self,
descriptor: GooglePubSub,
runtime: &Handle,
) -> Result<PubSubSubscriber, PubSubError> {
self.ensure_open()?;
let policy = descriptor.dead_letter_policy();
let state = match descriptor.create_topic_ref() {
Some(topic) => {
self.ensure_topic(topic).await?;
if let Some((dead_letter, _)) = policy {
self.ensure_topic(dead_letter).await?;
}
self.ensure_subscription(descriptor.subscription(), topic, policy)
.await?
}
None => Resource::Existing,
};
if let (Some((dead_letter, attempts)), Resource::Existing) = (policy, state) {
self.set_dead_letter_policy(descriptor.subscription(), dead_letter, attempts)
.await?;
}
#[cfg(feature = "testing")]
self.learn_attachment(&descriptor).await?;
Ok(PubSubSubscriber::open(self, &descriptor, runtime))
}
#[cfg(feature = "testing")]
async fn learn_attachment(&self, descriptor: &GooglePubSub) -> Result<(), PubSubError> {
let name = self.subscription_name(descriptor.subscription());
let reported = self
.subscription_admin
.get_subscription()
.set_subscription(name.clone())
.send()
.await
.map(|subscription| subscription.topic);
let topic = match reported {
Ok(topic) if !topic.is_empty() => topic,
Err(err) if err.http_status_code() == Some(404) => {
return Ok(());
}
reported => match descriptor.create_topic_ref() {
Some(topic) => self.topic_name(topic),
None => {
return Err(PubSubError::Admin {
name,
source: reported.err().map_or_else(
|| Box::from("the service reports no topic for the subscription"),
box_err,
),
});
}
},
};
self.attached
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(name, topic);
Ok(())
}
#[cfg(feature = "testing")]
fn attached_topic(&self, subscription: &str) -> Option<String> {
self.attached
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(&self.subscription_name(subscription))
.cloned()
}
async fn set_dead_letter_policy(
&self,
subscription: &str,
dead_letter: &str,
attempts: i32,
) -> Result<(), PubSubError> {
let name = self.subscription_name(subscription);
let policy = DeadLetterPolicy::new()
.set_dead_letter_topic(self.topic_name(dead_letter))
.set_max_delivery_attempts(attempts);
self.subscription_admin
.update_subscription()
.set_subscription(
Subscription::new()
.set_name(name.clone())
.set_dead_letter_policy(policy),
)
.set_update_mask(FieldMask::default().set_paths(["dead_letter_policy"]))
.send()
.await
.map_err(|err| PubSubError::Admin {
name,
source: box_err(err),
})?;
Ok(())
}
async fn ensure_topic(&self, topic: &str) -> Result<(), PubSubError> {
let name = self.topic_name(topic);
let admin = &self.topic_admin;
if admin
.get_topic()
.set_topic(name.clone())
.send()
.await
.is_ok()
{
return Ok(());
}
match admin.create_topic().set_name(name.clone()).send().await {
Ok(_) => Ok(()),
Err(create_err) => {
if admin
.get_topic()
.set_topic(name.clone())
.send()
.await
.is_ok()
{
Ok(())
} else {
Err(PubSubError::Admin {
name,
source: box_err(create_err),
})
}
}
}
}
async fn ensure_subscription(
&self,
subscription: &str,
topic: &str,
dead_letter: Option<(&str, i32)>,
) -> Result<Resource, PubSubError> {
let name = self.subscription_name(subscription);
let topic_name = self.topic_name(topic);
let admin = &self.subscription_admin;
if admin
.get_subscription()
.set_subscription(name.clone())
.send()
.await
.is_ok()
{
return Ok(Resource::Existing);
}
let mut create = admin
.create_subscription()
.set_name(name.clone())
.set_topic(topic_name)
.set_enable_message_ordering(true);
if let Some((dead_letter, attempts)) = dead_letter {
create = create.set_dead_letter_policy(
DeadLetterPolicy::new()
.set_dead_letter_topic(self.topic_name(dead_letter))
.set_max_delivery_attempts(attempts),
);
}
match create.send().await {
Ok(_) => Ok(Resource::Created),
Err(create_err) => {
if admin
.get_subscription()
.set_subscription(name.clone())
.send()
.await
.is_ok()
{
Ok(Resource::Existing)
} else {
Err(PubSubError::Admin {
name,
source: box_err(create_err),
})
}
}
}
}
async fn shutdown(&self) {
self.closed.store(true, Ordering::Release);
let publishers: Vec<_> = self.publishers.lock().await.values().cloned().collect();
for publisher in publishers {
publisher.flush().await;
}
}
}
impl std::fmt::Debug for Core {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Core")
.field("project", &self.project)
.field("closed", &self.closed.load(Ordering::Relaxed))
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub(crate) enum Link {
Service(Arc<Core>),
#[cfg(feature = "testing")]
InProcess(Arc<Project>),
}
#[cfg(not(feature = "testing"))]
const _: () = assert!(size_of::<Link>() == size_of::<Arc<Core>>());
impl Link {
fn declared_retries(&self) -> &DeclaredRetries {
match self {
Self::Service(core) => &core.declared_retries,
#[cfg(feature = "testing")]
Self::InProcess(project) => project.declared_retries(),
}
}
}
pub(crate) type CoreCell = Arc<OnceCell<Link>>;
#[derive(Clone)]
#[must_use]
pub struct PubSubBroker {
project: String,
credentials: Option<Credentials>,
endpoint: Option<String>,
emulator: Option<String>,
cell: CoreCell,
}
impl std::fmt::Debug for PubSubBroker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PubSubBroker")
.field("project", &self.project)
.field("emulator", &self.emulator)
.finish_non_exhaustive()
}
}
impl PubSubBroker {
pub fn new(project: impl Into<String>) -> Self {
Self {
project: project.into(),
credentials: None,
endpoint: None,
emulator: None,
cell: Arc::new(OnceCell::new()),
}
}
pub fn credentials(mut self, credentials: Credentials) -> Self {
self.credentials = Some(credentials);
self
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub fn emulator(mut self, host: impl Into<String>) -> Self {
self.emulator = Some(host.into());
self
}
#[must_use]
pub fn publisher(&self) -> PubSubPublisher {
PubSubPublisher::new(Arc::clone(&self.cell))
}
}
macro_rules! build_client {
($builder:expr, $endpoint:expr, $credentials:expr) => {{
let mut b = $builder;
if let Some(endpoint) = $endpoint {
b = b.with_endpoint(endpoint.clone());
}
if let Some(credentials) = $credentials {
b = b.with_credentials(credentials.clone());
}
b.build()
.await
.map_err(|e| PubSubError::Connect(box_err(e)))
}};
}
impl Broker for PubSubBroker {
type Error = PubSubError;
type Connected = ConnectedPubSubBroker;
async fn connect(self) -> Result<Self::Connected, Self::Error> {
let link = self
.cell
.get_or_try_init(async || {
let (endpoint, credentials) = if let Some(host) = &self.emulator {
(
Some(format!("http://{host}")),
Some(anonymous::Builder::new().build()),
)
} else {
(self.endpoint.clone(), self.credentials.clone())
};
let subscriber: Subscriber =
build_client!(Subscriber::builder(), &endpoint, &credentials)?;
let base_publisher: BasePublisher =
build_client!(BasePublisher::builder(), &endpoint, &credentials)?;
let topic_admin: TopicAdmin =
build_client!(TopicAdmin::builder(), &endpoint, &credentials)?;
let subscription_admin: SubscriptionAdmin =
build_client!(SubscriptionAdmin::builder(), &endpoint, &credentials)?;
Ok::<_, PubSubError>(Link::Service(Arc::new(Core {
subscriber,
base_publisher,
topic_admin,
subscription_admin,
project: self.project.clone(),
closed: AtomicBool::new(false),
publishers: tokio::sync::Mutex::new(HashMap::new()),
declared_retries: DeclaredRetries::default(),
runtime: RuntimeRegistration::new(Handle::current()),
#[cfg(feature = "testing")]
attached: Mutex::new(HashMap::new()),
})))
})
.await?
.clone();
Ok(ConnectedPubSubBroker {
link,
cell: self.cell,
runtime: Handle::current(),
})
}
}
#[cfg(feature = "testing")]
impl InProcess for PubSubBroker {
fn connect_in_process(
self,
) -> impl Future<Output = Result<Self::Connected, Self::Error>> + Send {
let link = self.cell.get().cloned().unwrap_or_else(|| {
let project = Link::InProcess(Project::new(self.project.clone(), Handle::current()));
let _ = self.cell.set(project.clone());
self.cell.get().cloned().unwrap_or(project)
});
if let Link::Service(_) = link {
return ready(Err(PubSubError::Connect(Box::from(
"a clone of this broker is connected to the service already, so it cannot \
connect in process",
))));
}
ready(Ok(ConnectedPubSubBroker {
link,
cell: self.cell,
runtime: Handle::current(),
}))
}
}
#[cfg(feature = "testing")]
ruststream::register_testable_broker!(PubSubBroker);
impl DescribeServer for PubSubBroker {
fn describe_server(&self) -> ServerSpec {
let host = self
.emulator
.as_deref()
.or(self.endpoint.as_deref())
.map_or_else(
|| "pubsub.googleapis.com".to_owned(),
ServerSpec::host_from_url,
);
ServerSpec::new(host, "googlepubsub")
}
}
#[derive(Debug)]
pub struct ConnectedPubSubBroker {
link: Link,
cell: CoreCell,
runtime: Handle,
}
impl ConnectedPubSubBroker {
#[must_use]
pub fn publisher(&self) -> PubSubPublisher {
PubSubPublisher::new(Arc::clone(&self.cell))
}
pub async fn subscribe_descriptor(
&self,
descriptor: GooglePubSub,
) -> Result<PubSubSubscriber, PubSubError> {
descriptor.validate()?;
let core = match &self.link {
Link::Service(core) => core,
#[cfg(feature = "testing")]
Link::InProcess(project) => {
return in_process::subscribe(project, &descriptor);
}
};
core.subscribe(descriptor, &self.runtime).await
}
}
impl ConnectedBroker for ConnectedPubSubBroker {
type Error = PubSubError;
type Closed = ();
async fn shutdown(self) -> Result<(), Self::Error> {
match &self.link {
Link::Service(core) => core.shutdown().await,
#[cfg(feature = "testing")]
Link::InProcess(project) => project.close(),
}
Ok(())
}
}
impl Subscribe for ConnectedPubSubBroker {
type Subscriber = PubSubSubscriber;
type Copies = BrokerMoves;
async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
self.subscribe_descriptor(self.link.declared_retries().source(name))
.await
}
fn declare_retry(
&self,
name: &str,
declaration: &RetryDeclaration,
) -> Result<(), DeclareRetryError> {
self.link.declared_retries().take(name, declaration)
}
}
impl DefaultPublish for ConnectedPubSubBroker {
type Policy = PubSubPublish;
}
#[cfg(feature = "testing")]
impl TestableBroker for ConnectedPubSubBroker {
fn install_coordinator(&self, coordinator: Coordinator) {
if let Link::InProcess(project) = &self.link {
project.install(coordinator);
}
}
fn inject(&self, message: OutgoingMessage<'_>) {
let project = self.project("inject");
let wire = resolve_ordering_key(message.headers(), None, None)
.and_then(|key| {
to_gcp_message(BytesMut::from(message.payload()), message.headers(), key)
})
.map_err(|reason| PubSubError::Publish {
topic: project.topic_path(message.name()),
source: reason.into(),
});
if let Err(err) = wire.and_then(|wire| project.publish(message.name(), &wire)) {
panic!(
"the injected message to {:?} is not one Pub/Sub takes: {err}",
message.name()
);
}
}
fn published(&self, name: &str) -> Vec<RawMessage> {
self.project("published").published(name)
}
fn routes(&self, destination: &str, subscriptions: &[&str]) -> Vec<usize> {
let core = match &self.link {
Link::Service(core) => core,
Link::InProcess(project) => return project.routes(destination, subscriptions),
};
let topic = core.topic_name(destination);
let attached: Vec<Option<String>> = subscriptions
.iter()
.map(|name| core.attached_topic(name))
.collect();
attached
.iter()
.enumerate()
.filter(|(_, bound)| bound.as_deref() == Some(topic.as_str()))
.map(|(position, _)| position)
.collect()
}
}
#[cfg(feature = "testing")]
impl ConnectedPubSubBroker {
fn project(&self, what: &str) -> &Arc<Project> {
match &self.link {
Link::InProcess(project) => project,
Link::Service(_) => panic!(
"TestableBroker::{what} reached a broker connected with `connect`; the harness \
drives the connection `connect_in_process` produces"
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn described(broker: &PubSubBroker) -> String {
broker
.describe_server()
.host
.expect("a Pub/Sub server always has an address")
}
#[test]
fn the_default_server_is_the_public_endpoint() {
assert_eq!(
described(&PubSubBroker::new("p")),
"pubsub.googleapis.com".to_owned()
);
}
#[test]
fn an_emulator_address_is_reported_as_written() {
assert_eq!(
described(&PubSubBroker::new("p").emulator("localhost:8085")),
"localhost:8085".to_owned()
);
}
#[test]
fn an_endpoint_is_reported_as_host_and_port() {
for (written, expected) in [
("localhost:8085", "localhost:8085"),
("http://localhost:8085", "localhost:8085"),
(
"https://us-east1-pubsub.googleapis.com",
"us-east1-pubsub.googleapis.com",
),
("https://pubsub.googleapis.com/v1", "pubsub.googleapis.com"),
(
"https://pubsub.googleapis.com/v1?alt=json",
"pubsub.googleapis.com",
),
("http://user:pass@localhost:8085", "localhost:8085"),
("https://pubsub.googleapis.com/a@b", "pubsub.googleapis.com"),
("http://user:p@ss@localhost:8085", "localhost:8085"),
("http://[::1]:8085", "[::1]:8085"),
] {
let host = described(&PubSubBroker::new("p").endpoint(written));
assert_eq!(host, expected.to_owned(), "endpoint {written:?}");
assert!(
!host.contains("://"),
"scheme reached the description: {host}"
);
assert!(
!host.contains('@'),
"credentials reached the description: {host}"
);
}
}
#[test]
fn an_emulator_endpoint_is_stripped_the_same_way() {
let host = described(&PubSubBroker::new("p").emulator("http://user:pass@127.0.0.1:8085"));
assert_eq!(host, "127.0.0.1:8085".to_owned());
}
}