use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard};
use std::time::Duration;
#[cfg(feature = "asyncapi")]
use ruststream::asyncapi::Bindings;
use ruststream::runtime::IntoSource;
use ruststream::{BrokerMoves, DeclareRetryError, RetryDeclaration, SubscriptionSource};
#[cfg(feature = "asyncapi")]
use serde::Serialize;
#[cfg(feature = "asyncapi")]
use crate::bindings::{extension, topic_channel};
use crate::broker::ConnectedPulsarBroker;
use crate::error::PulsarError;
use crate::pattern::TopicPattern;
use crate::subscriber::PulsarSubscriber;
use crate::topic::PulsarTopic;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SubscriptionType {
Exclusive,
#[default]
Shared,
Failover,
KeyShared,
}
impl SubscriptionType {
pub(crate) const fn counts_redeliveries(self) -> bool {
matches!(self, Self::Shared | Self::KeyShared)
}
}
#[cfg(feature = "asyncapi")]
impl SubscriptionType {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Exclusive => "Exclusive",
Self::Shared => "Shared",
Self::Failover => "Failover",
Self::KeyShared => "KeyShared",
}
}
}
#[cfg(feature = "asyncapi")]
#[derive(Serialize)]
struct PulsarOperation<'a> {
subscription: &'a str,
#[serde(rename = "subscriptionType")]
subscription_type: &'static str,
#[serde(rename = "ackTimeoutMillis", skip_serializing_if = "Option::is_none")]
ack_timeout_millis: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
topics: Option<&'a [String]>,
#[serde(rename = "topicPattern", skip_serializing_if = "Option::is_none")]
topic_pattern: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DeadLetterRoute {
pub(crate) topic: String,
pub(crate) max_deliveries: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Topics {
List(Vec<String>),
Pattern(String),
}
pub(crate) const DEFAULT_BATCH_WAIT: Duration = Duration::from_millis(10);
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use]
pub struct PulsarSubscription {
pub(crate) topics: Topics,
pub(crate) subscription: String,
pub(crate) sub_type: SubscriptionType,
pub(crate) ack_timeout: Option<Duration>,
pub(crate) batch_wait: Duration,
retry: RetryDeclaration,
}
impl PulsarSubscription {
pub fn new(topic: impl Into<String>, subscription: impl Into<String>) -> Self {
Self {
topics: Topics::List(vec![topic.into()]),
subscription: subscription.into(),
sub_type: SubscriptionType::default(),
ack_timeout: None,
batch_wait: DEFAULT_BATCH_WAIT,
retry: RetryDeclaration::new(),
}
}
pub fn topics<I, S>(topics: I, subscription: impl Into<String>) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
topics: Topics::List(topics.into_iter().map(Into::into).collect()),
..Self::new(String::new(), subscription)
}
}
pub fn pattern(pattern: impl Into<String>, subscription: impl Into<String>) -> Self {
Self {
topics: Topics::Pattern(pattern.into()),
..Self::new(String::new(), subscription)
}
}
pub fn subscription_type(mut self, sub_type: SubscriptionType) -> Self {
self.sub_type = sub_type;
self
}
pub fn ack_timeout(mut self, timeout: Duration) -> Self {
self.ack_timeout = Some(timeout);
self
}
pub fn batch_wait(mut self, wait: Duration) -> Self {
self.batch_wait = wait;
self
}
#[must_use]
pub fn subscription(&self) -> &str {
&self.subscription
}
pub(crate) fn declaring(mut self, declaration: &RetryDeclaration) -> Self {
self.retry = declaration.clone();
self
}
pub(crate) fn source_name(&self) -> &str {
match &self.topics {
Topics::List(topics) if topics.len() == 1 => &topics[0],
_ => &self.subscription,
}
}
pub(crate) fn dead_letter_policy(&self) -> Result<Option<DeadLetterRoute>, PulsarError> {
let subject = format!("subscription '{}'", self.subscription);
let route = resolve_dead_letter(&self.retry, &subject)?;
if route.is_some() && !self.sub_type.counts_redeliveries() {
let sharing = self.sub_type;
return Err(PulsarError::Invalid(format!(
"{subject} declares a retry cap on a {sharing:?} subscription: Pulsar counts a \
message's redeliveries only where a subscription dispatches to competing \
consumers, so on this one the cap is never reached and a spent delivery circles \
the subscription for ever. Declare the subscription \
SubscriptionType::Shared or SubscriptionType::KeyShared, or drop the cap and the \
dead-letter topic"
)));
}
Ok(route)
}
#[cfg(feature = "asyncapi")]
fn describe_channel(&self) -> Bindings {
let Topics::List(topics) = &self.topics else {
return Bindings::new();
};
let mut parsed = topics.iter().map(|topic| PulsarTopic::parse(topic));
let Some(Ok(first)) = parsed.next() else {
return Bindings::new();
};
for rest in parsed {
match rest {
Ok(topic)
if topic.namespace() == first.namespace()
&& topic.persistence() == first.persistence() => {}
_ => return Bindings::new(),
}
}
topic_channel(&first)
}
#[cfg(feature = "asyncapi")]
fn describe_operation(&self) -> Bindings {
let (topics, topic_pattern) = match &self.topics {
Topics::List(topics) if topics.len() > 1 => (Some(topics.as_slice()), None),
Topics::List(_) => (None, None),
Topics::Pattern(pattern) => (None, Some(pattern.as_str())),
};
let body = PulsarOperation {
subscription: &self.subscription,
subscription_type: self.sub_type.as_str(),
ack_timeout_millis: self
.ack_timeout
.map(|timeout| u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)),
topics,
topic_pattern,
};
extension("x-ruststream-pulsar", &body)
}
pub(crate) fn display_topic(&self) -> String {
match &self.topics {
Topics::List(topics) => topics.join(","),
Topics::Pattern(pattern) => pattern.clone(),
}
}
pub(crate) fn validate(&self) -> Result<(), PulsarError> {
if self.subscription.is_empty() {
return Err(PulsarError::Invalid(
"subscription name must be non-empty".into(),
));
}
match &self.topics {
Topics::List(topics) => {
if topics.is_empty() || topics.iter().any(String::is_empty) {
return Err(PulsarError::Invalid("topics must be non-empty".into()));
}
for topic in topics {
let _ = PulsarTopic::parse(topic)?;
}
}
Topics::Pattern(pattern) => {
TopicPattern::parse(pattern)?;
}
}
self.dead_letter_policy()?;
Ok(())
}
}
fn resolve_dead_letter(
retry: &RetryDeclaration,
subject: &str,
) -> Result<Option<DeadLetterRoute>, PulsarError> {
match (retry.max_attempts(), retry.dead_letter()) {
(Some(attempts), Some(topic)) => {
let _ = PulsarTopic::parse(topic)?;
Ok(Some(DeadLetterRoute {
topic: topic.to_owned(),
max_deliveries: attempts.get(),
}))
}
(None, None) => Ok(None),
(Some(_), None) => Err(PulsarError::Invalid(format!(
"{subject} declares max_attempts with no dead_letter: Pulsar applies a delivery \
limit only together with the topic a spent delivery moves to, so declare \
dead_letter(..) beside it or drop both"
))),
(None, Some(topic)) => Err(PulsarError::Invalid(format!(
"{subject} declares dead_letter('{topic}') with no max_attempts: Pulsar moves a \
delivery to the dead-letter topic by counting redeliveries, so declare \
max_attempts(..) beside it or drop both"
))),
}
}
#[derive(Debug, Default)]
pub(crate) struct DeclaredRetries {
by_topic: Mutex<HashMap<String, RetryDeclaration>>,
}
impl DeclaredRetries {
pub(crate) fn declare(
&self,
topic: &str,
declaration: &RetryDeclaration,
) -> Result<(), DeclareRetryError> {
resolve_dead_letter(declaration, &format!("topic '{topic}'"))
.map_err(|err| DeclareRetryError::Broker(Box::new(err)))?;
self.lock().insert(topic.to_owned(), declaration.clone());
Ok(())
}
pub(crate) fn subscription(&self, topic: &str, subscription: &str) -> PulsarSubscription {
let declared = self.lock().get(topic).cloned().unwrap_or_default();
PulsarSubscription::new(topic, subscription).declaring(&declared)
}
fn lock(&self) -> MutexGuard<'_, HashMap<String, RetryDeclaration>> {
self.by_topic
.lock()
.expect("the declarations are read and written without panicking")
}
}
impl IntoSource for PulsarSubscription {
type Source = Self;
fn into_source(self) -> Self {
self
}
}
impl SubscriptionSource<ConnectedPulsarBroker> for PulsarSubscription {
type Subscriber = PulsarSubscriber;
type Copies = BrokerMoves;
fn name(&self) -> &str {
self.source_name()
}
async fn subscribe(
self,
connected: &ConnectedPulsarBroker,
) -> Result<PulsarSubscriber, PulsarError> {
connected.subscribe_descriptor(self).await
}
fn declare_retry(self, declaration: &RetryDeclaration) -> Self {
self.declaring(declaration)
}
#[cfg(feature = "asyncapi")]
fn channel_bindings(&self) -> Bindings {
self.describe_channel()
}
#[cfg(feature = "asyncapi")]
fn operation_bindings(&self) -> Bindings {
self.describe_operation()
}
}
#[cfg(test)]
mod tests {
use ruststream::nonzero;
use super::*;
fn declared(
subscription: PulsarSubscription,
declaration: &RetryDeclaration,
) -> PulsarSubscription {
SubscriptionSource::declare_retry(subscription, declaration)
}
#[test]
fn empty_subscription_is_rejected_before_io() {
assert!(matches!(
PulsarSubscription::new("orders", "").validate(),
Err(PulsarError::Invalid(_))
));
}
#[test]
fn malformed_topics_are_rejected_before_io() {
assert!(matches!(
PulsarSubscription::new("a/b", "workers").validate(),
Err(PulsarError::Invalid(_))
));
}
#[test]
fn half_a_retry_declaration_is_rejected_before_io() {
let capped = declared(
PulsarSubscription::new("orders", "workers"),
&RetryDeclaration::new().with_max_attempts(nonzero!(3u32)),
);
let message = match capped.validate() {
Err(PulsarError::Invalid(message)) => message,
other => panic!("a cap with no destination must be refused, got {other:?}"),
};
assert!(message.contains("dead_letter"), "{message}");
let addressed = declared(
PulsarSubscription::new("orders", "workers"),
&RetryDeclaration::new().with_dead_letter("orders-dlq"),
);
let message = match addressed.validate() {
Err(PulsarError::Invalid(message)) => message,
other => panic!("a destination with no cap must be refused, got {other:?}"),
};
assert!(message.contains("max_attempts"), "{message}");
}
#[test]
fn a_whole_declaration_becomes_the_consumers_policy() {
let policy = declared(
PulsarSubscription::new("orders", "workers"),
&RetryDeclaration::new()
.with_max_attempts(nonzero!(3u32))
.with_dead_letter("orders-dlq"),
);
assert_eq!(
policy.dead_letter_policy().expect("both halves declared"),
Some(DeadLetterRoute {
topic: "orders-dlq".to_owned(),
max_deliveries: 3,
})
);
}
#[test]
fn a_cap_on_a_single_active_subscription_is_rejected_before_io() {
let whole = RetryDeclaration::new()
.with_max_attempts(nonzero!(3u32))
.with_dead_letter("orders-dlq");
for sharing in [SubscriptionType::Exclusive, SubscriptionType::Failover] {
let capped = declared(
PulsarSubscription::new("orders", "workers").subscription_type(sharing),
&whole,
);
let message = match capped.validate() {
Err(PulsarError::Invalid(message)) => message,
other => {
panic!("a cap on a {sharing:?} subscription must be refused, got {other:?}")
}
};
assert!(message.contains(&format!("{sharing:?}")), "{message}");
assert!(message.contains("KeyShared"), "{message}");
}
}
#[test]
fn a_single_active_subscription_without_a_cap_is_accepted() {
for sharing in [SubscriptionType::Exclusive, SubscriptionType::Failover] {
PulsarSubscription::new("orders", "workers")
.subscription_type(sharing)
.validate()
.expect("a subscription that declares no cap opens on any type");
}
}
#[test]
fn malformed_patterns_are_rejected_before_io() {
assert!(matches!(
PulsarSubscription::pattern("orders-(", "workers").validate(),
Err(PulsarError::Invalid(_))
));
}
}