Skip to main content

ruststream_pulsar/
subscription.rs

1//! [`PulsarSubscription`]: the subscription descriptor.
2//!
3//! The subscription type is an enum, not a set of sibling flags, so combinations that do not
4//! exist are unrepresentable; the dead-letter policy and the ack timeout are consumer-side
5//! settings the product owns.
6
7use std::time::Duration;
8
9use ruststream::SubscriptionSource;
10
11use crate::broker::ConnectedPulsarBroker;
12use crate::error::PulsarError;
13use crate::subscriber::PulsarSubscriber;
14use crate::topic::PulsarTopic;
15
16/// How competing consumers on one subscription share its messages.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum SubscriptionType {
19    /// One consumer holds the subscription; a second attach is rejected.
20    Exclusive,
21    /// Competing consumers, round-robin. The default.
22    #[default]
23    Shared,
24    /// One active consumer with hot standbys.
25    Failover,
26    /// Competing consumers with per-key ordering (the `Partitioned` capability's transport).
27    KeyShared,
28}
29
30/// The consumer-side dead-letter policy: after `max_deliveries` redeliveries the broker routes
31/// the message to the dead-letter topic.
32///
33/// # Examples
34///
35/// ```
36/// use ruststream_pulsar::DeadLetter;
37///
38/// let policy = DeadLetter::new("orders-dlq").max_deliveries(5);
39/// # let _ = policy;
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[must_use]
43pub struct DeadLetter {
44    pub(crate) topic: String,
45    pub(crate) max_deliveries: usize,
46}
47
48impl DeadLetter {
49    /// Routes exhausted messages to `topic` after the default of 5 deliveries.
50    pub fn new(topic: impl Into<String>) -> Self {
51        Self {
52            topic: topic.into(),
53            max_deliveries: 5,
54        }
55    }
56
57    /// Sets the delivery-attempt limit.
58    pub fn max_deliveries(mut self, max: usize) -> Self {
59        self.max_deliveries = max;
60        self
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub(crate) enum Topics {
66    List(Vec<String>),
67    Pattern(String),
68}
69
70/// A subscription descriptor for one Pulsar subscription over one or more topics.
71///
72/// Where the subscription starts reading is not a descriptor option: it is the framework's
73/// `start_at(..)` clause over [`PulsarPosition`](crate::PulsarPosition), which the `Seekable`
74/// capability backs.
75///
76/// Implements [`SubscriptionSource`], so it can sit inline in the `#[subscriber(..)]`
77/// decorator:
78///
79/// ```
80/// use std::time::Duration;
81/// use ruststream_pulsar::{DeadLetter, PulsarSubscription, SubscriptionType};
82///
83/// let source = PulsarSubscription::new("orders", "workers")
84///     .subscription_type(SubscriptionType::Shared)
85///     .dead_letter(DeadLetter::new("orders-dlq").max_deliveries(5))
86///     .ack_timeout(Duration::from_secs(30));
87/// # let _ = source;
88/// ```
89#[derive(Debug, Clone, PartialEq, Eq)]
90#[must_use]
91pub struct PulsarSubscription {
92    pub(crate) topics: Topics,
93    pub(crate) subscription: String,
94    pub(crate) sub_type: SubscriptionType,
95    pub(crate) dead_letter: Option<DeadLetter>,
96    pub(crate) ack_timeout: Option<Duration>,
97}
98
99impl PulsarSubscription {
100    /// Subscribes `subscription` to `topic` (a bare name, a `tenant/namespace/topic` triple,
101    /// or a fully qualified name).
102    pub fn new(topic: impl Into<String>, subscription: impl Into<String>) -> Self {
103        Self {
104            topics: Topics::List(vec![topic.into()]),
105            subscription: subscription.into(),
106            sub_type: SubscriptionType::default(),
107            dead_letter: None,
108            ack_timeout: None,
109        }
110    }
111
112    /// Subscribes to every topic in the list.
113    pub fn topics<I, S>(topics: I, subscription: impl Into<String>) -> Self
114    where
115        I: IntoIterator<Item = S>,
116        S: Into<String>,
117    {
118        Self {
119            topics: Topics::List(topics.into_iter().map(Into::into).collect()),
120            ..Self::new(String::new(), subscription)
121        }
122    }
123
124    /// Subscribes to every topic in the lookup namespace whose name matches `pattern` (a
125    /// regular expression, validated on subscribe).
126    pub fn pattern(pattern: impl Into<String>, subscription: impl Into<String>) -> Self {
127        Self {
128            topics: Topics::Pattern(pattern.into()),
129            ..Self::new(String::new(), subscription)
130        }
131    }
132
133    /// Sets the subscription type. Defaults to [`SubscriptionType::Shared`].
134    pub fn subscription_type(mut self, sub_type: SubscriptionType) -> Self {
135        self.sub_type = sub_type;
136        self
137    }
138
139    /// Sets the consumer-side dead-letter policy.
140    pub fn dead_letter(mut self, dead_letter: DeadLetter) -> Self {
141        self.dead_letter = Some(dead_letter);
142        self
143    }
144
145    /// Redelivers messages that stay unacknowledged longer than `timeout`.
146    pub fn ack_timeout(mut self, timeout: Duration) -> Self {
147        self.ack_timeout = Some(timeout);
148        self
149    }
150
151    /// The subscription name.
152    #[must_use]
153    pub fn subscription(&self) -> &str {
154        &self.subscription
155    }
156
157    pub(crate) fn display_topic(&self) -> String {
158        match &self.topics {
159            Topics::List(topics) => topics.join(","),
160            Topics::Pattern(pattern) => pattern.clone(),
161        }
162    }
163
164    /// Rejects descriptors that cannot form a subscription, before any I/O.
165    pub(crate) fn validate(&self) -> Result<(), PulsarError> {
166        if self.subscription.is_empty() {
167            return Err(PulsarError::Invalid(
168                "subscription name must be non-empty".into(),
169            ));
170        }
171        match &self.topics {
172            Topics::List(topics) => {
173                if topics.is_empty() || topics.iter().any(String::is_empty) {
174                    return Err(PulsarError::Invalid("topics must be non-empty".into()));
175                }
176                for topic in topics {
177                    let _ = PulsarTopic::parse(topic)?;
178                }
179            }
180            Topics::Pattern(pattern) => {
181                regex::Regex::new(pattern).map_err(|e| {
182                    PulsarError::Invalid(format!("invalid topic pattern '{pattern}': {e}"))
183                })?;
184            }
185        }
186        Ok(())
187    }
188}
189
190impl SubscriptionSource<ConnectedPulsarBroker> for PulsarSubscription {
191    type Subscriber = PulsarSubscriber;
192
193    fn name(&self) -> &str {
194        match &self.topics {
195            Topics::List(topics) if topics.len() == 1 => &topics[0],
196            _ => &self.subscription,
197        }
198    }
199
200    async fn subscribe(
201        self,
202        connected: &ConnectedPulsarBroker,
203    ) -> Result<PulsarSubscriber, PulsarError> {
204        connected.subscribe_descriptor(self).await
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn empty_subscription_is_rejected_before_io() {
214        assert!(matches!(
215            PulsarSubscription::new("orders", "").validate(),
216            Err(PulsarError::Invalid(_))
217        ));
218    }
219
220    #[test]
221    fn malformed_topics_are_rejected_before_io() {
222        assert!(matches!(
223            PulsarSubscription::new("a/b", "workers").validate(),
224            Err(PulsarError::Invalid(_))
225        ));
226    }
227
228    #[test]
229    fn malformed_patterns_are_rejected_before_io() {
230        assert!(matches!(
231            PulsarSubscription::pattern("orders-(", "workers").validate(),
232            Err(PulsarError::Invalid(_))
233        ));
234    }
235}