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 ack timeout and the dead-letter policy the registration
5//! declares are consumer-side settings the Pulsar client owns.
6
7use std::collections::HashMap;
8use std::sync::{Mutex, MutexGuard};
9use std::time::Duration;
10
11#[cfg(feature = "asyncapi")]
12use ruststream::asyncapi::Bindings;
13use ruststream::runtime::IntoSource;
14use ruststream::{BrokerMoves, DeclareRetryError, RetryDeclaration, SubscriptionSource};
15#[cfg(feature = "asyncapi")]
16use serde::Serialize;
17
18#[cfg(feature = "asyncapi")]
19use crate::bindings::{extension, topic_channel};
20use crate::broker::ConnectedPulsarBroker;
21use crate::error::PulsarError;
22use crate::pattern::TopicPattern;
23use crate::subscriber::PulsarSubscriber;
24use crate::topic::PulsarTopic;
25
26/// How competing consumers on one subscription share its messages.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum SubscriptionType {
29    /// One consumer holds the subscription.
30    ///
31    /// A server refuses a second attach as busy, and the client retries it until
32    /// [`OperationRetries`](crate::OperationRetries) runs out, which by default is never: a second
33    /// consumer of this subscription is a service that does not start. A bound on the retries
34    /// turns the wait into [`PulsarError::Subscribe`](crate::PulsarError::Subscribe). In process,
35    /// under the test harness, the attach is refused with that error at once, so the test fails
36    /// rather than hangs.
37    Exclusive,
38    /// Competing consumers, round-robin. The default.
39    #[default]
40    Shared,
41    /// One active consumer with hot standbys.
42    Failover,
43    /// Competing consumers with per-key ordering (the `Partitioned` capability's transport).
44    KeyShared,
45}
46
47impl SubscriptionType {
48    /// Whether the broker counts a message's redeliveries on a subscription of this type.
49    ///
50    /// It counts them where the subscription dispatches to several consumers, and nowhere else:
51    /// a subscription with one active consumer keeps no redelivery tracker, so every delivery
52    /// arrives with a count of zero. A dead-letter policy is that count against a limit, so on
53    /// [`Exclusive`](Self::Exclusive) and [`Failover`](Self::Failover) the limit is never
54    /// reached and a spent message circles the subscription instead of moving on.
55    pub(crate) const fn counts_redeliveries(self) -> bool {
56        matches!(self, Self::Shared | Self::KeyShared)
57    }
58}
59
60#[cfg(feature = "asyncapi")]
61impl SubscriptionType {
62    /// Pulsar's own spelling, which is what the generated document reports.
63    pub(crate) const fn as_str(self) -> &'static str {
64        match self {
65            Self::Exclusive => "Exclusive",
66            Self::Shared => "Shared",
67            Self::Failover => "Failover",
68            Self::KeyShared => "KeyShared",
69        }
70    }
71}
72
73/// What a Pulsar consumer is, in the crate's own vocabulary.
74///
75/// The specification's `pulsar` operation object is empty, so the subscription's name, its type
76/// and its acknowledgement timeout travel under an extension key instead of being squeezed into
77/// a field that means something else.
78#[cfg(feature = "asyncapi")]
79#[derive(Serialize)]
80struct PulsarOperation<'a> {
81    subscription: &'a str,
82    #[serde(rename = "subscriptionType")]
83    subscription_type: &'static str,
84    #[serde(rename = "ackTimeoutMillis", skip_serializing_if = "Option::is_none")]
85    ack_timeout_millis: Option<u64>,
86    /// The topics a multi-topic subscription reads. Absent for a single topic, which the
87    /// channel address already names, and for a pattern, which names none.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    topics: Option<&'a [String]>,
90    /// The regular expression a pattern subscription selects its topics with.
91    #[serde(rename = "topicPattern", skip_serializing_if = "Option::is_none")]
92    topic_pattern: Option<&'a str>,
93}
94
95/// The consumer's dead-letter policy, as the registration declared it: a delivery limit and the
96/// topic a spent delivery moves to.
97///
98/// Both halves together, because that is what the Pulsar client applies. The live consumer and
99/// the in-process transport read this one value, so a cap driven in a test is the cap production
100/// configures.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub(crate) struct DeadLetterRoute {
103    pub(crate) topic: String,
104    /// How many times the message is handed to a handler before it moves on.
105    pub(crate) max_deliveries: u32,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub(crate) enum Topics {
110    List(Vec<String>),
111    Pattern(String),
112}
113
114/// How long a partial batch waits for more deliveries before the handler sees it, unless the
115/// descriptor names another value.
116///
117/// Short, because the cost of waiting is latency on a batch that is already useful; a service
118/// trading latency for fuller batches raises it with [`PulsarSubscription::batch_wait`].
119pub(crate) const DEFAULT_BATCH_WAIT: Duration = Duration::from_millis(10);
120
121/// A subscription descriptor for one Pulsar subscription over one or more topics.
122///
123/// It names the subscription, the topics it reads and the settings its consumer opens with:
124/// [`new`](Self::new) reads one topic, [`topics`](Self::topics) a fixed list and
125/// [`pattern`](Self::pattern) every topic whose full name a regular expression matches. Where the
126/// subscription starts reading is the framework's `start_at(..)` clause over
127/// [`PulsarPosition`](crate::PulsarPosition).
128///
129/// The registration declares the retry cap and the dead-letter topic at the mount site with
130/// `b.include(handler).max_attempts(nonzero!(5)).dead_letter("orders-dlq")`, and this descriptor
131/// turns the declaration into the consumer's own `DeadLetterPolicy`. The Pulsar client moves a
132/// spent delivery itself, so the registration takes no `out_retry(..)` step. The cap needs a
133/// subscription that dispatches to competing consumers - Pulsar counts a message's redeliveries
134/// on [`Shared`](SubscriptionType::Shared) and [`KeyShared`](SubscriptionType::KeyShared) - so a
135/// declaration over the other two types refuses to start.
136///
137/// # Examples
138///
139/// Implements [`SubscriptionSource`] for the broker, so the declaration sits inline in the
140/// `#[subscriber(..)]` decorator:
141///
142/// ```
143/// # mod demo {
144/// use std::time::Duration;
145///
146/// use ruststream_pulsar::prelude::*;
147/// use serde::Deserialize;
148///
149/// #[derive(Deserialize)]
150/// struct Order {
151///     id: u64,
152/// }
153///
154/// #[subscriber(
155///     PulsarSubscription::new("orders", "workers")
156///         .subscription_type(SubscriptionType::Shared)
157///         .ack_timeout(Duration::from_secs(30))
158/// )]
159/// async fn handle(order: &Order) -> HandlerOutcome {
160///     tracing::info!(order.id, "accepted");
161///     HandlerOutcome::ack()
162/// }
163///
164/// #[ruststream::app]
165/// fn app() -> impl App {
166///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
167///         PulsarBroker::new("pulsar://localhost:6650"),
168///         |b| {
169///             b.include(handle).max_attempts(nonzero!(5)).dead_letter("orders-dlq");
170///         },
171///     )
172/// }
173/// # }
174/// # fn main() {}
175/// ```
176///
177/// The three forms side by side, one handler each:
178///
179/// ```
180/// # mod demo {
181/// use std::time::Duration;
182///
183/// use ruststream_pulsar::prelude::*;
184/// use serde::Deserialize;
185///
186/// #[derive(Deserialize)]
187/// struct Order {
188///     id: u64,
189/// }
190///
191/// #[subscriber(PulsarSubscription::new("persistent://acme/orders/created", "workers"))]
192/// async fn fulfil(order: &Order) -> HandlerOutcome {
193///     tracing::info!(order.id, "fulfilling");
194///     HandlerOutcome::ack()
195/// }
196///
197/// #[subscriber(PulsarSubscription::topics(["orders-eu", "orders-us"], "regional"))]
198/// async fn tally(order: &Order) -> HandlerOutcome {
199///     tracing::info!(order.id, "counted");
200///     HandlerOutcome::ack()
201/// }
202///
203/// #[subscriber(
204///     PulsarSubscription::pattern("persistent://public/default/orders-.*", "audit")
205///         .subscription_type(SubscriptionType::KeyShared)
206///         .ack_timeout(Duration::from_secs(30))
207/// )]
208/// async fn audit(order: &Order) -> HandlerOutcome {
209///     tracing::info!(order.id, "audited");
210///     HandlerOutcome::ack()
211/// }
212///
213/// #[ruststream::app]
214/// fn app() -> impl App {
215///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
216///         PulsarBroker::new("pulsar://localhost:6650"),
217///         |b| {
218///             b.include(fulfil);
219///             b.include(tally);
220///             b.include(audit);
221///         },
222///     )
223/// }
224/// # }
225/// # fn main() {}
226/// ```
227#[derive(Debug, Clone, PartialEq, Eq)]
228#[must_use]
229pub struct PulsarSubscription {
230    pub(crate) topics: Topics,
231    pub(crate) subscription: String,
232    pub(crate) sub_type: SubscriptionType,
233    pub(crate) ack_timeout: Option<Duration>,
234    pub(crate) batch_wait: Duration,
235    /// What the registration declared about its retries, taken in before the consumer opens.
236    retry: RetryDeclaration,
237}
238
239impl PulsarSubscription {
240    /// Subscribes `subscription` to `topic` (a bare name, a `tenant/namespace/topic` triple,
241    /// or a fully qualified name).
242    pub fn new(topic: impl Into<String>, subscription: impl Into<String>) -> Self {
243        Self {
244            topics: Topics::List(vec![topic.into()]),
245            subscription: subscription.into(),
246            sub_type: SubscriptionType::default(),
247            ack_timeout: None,
248            batch_wait: DEFAULT_BATCH_WAIT,
249            retry: RetryDeclaration::new(),
250        }
251    }
252
253    /// Subscribes to every topic in the list.
254    pub fn topics<I, S>(topics: I, subscription: impl Into<String>) -> Self
255    where
256        I: IntoIterator<Item = S>,
257        S: Into<String>,
258    {
259        Self {
260            topics: Topics::List(topics.into_iter().map(Into::into).collect()),
261            ..Self::new(String::new(), subscription)
262        }
263    }
264
265    /// Subscribes to every topic of one namespace whose fully qualified name matches `pattern`,
266    /// a regular expression.
267    ///
268    /// The namespace is the one the pattern's prefix names: `persistent://acme/orders/.*` and
269    /// `acme/orders/.*` read `acme/orders`, and a pattern with no `tenant/namespace/` part, such
270    /// as `orders-.*`, reads `public/default`. The scheme is part of the regular expression, so
271    /// one pattern may select persistent and non-persistent topics alike. The pattern is checked
272    /// on subscribe: one that is no regular expression, or whose tenant or namespace is itself a
273    /// regular expression and so names no single namespace, refuses to start with
274    /// [`PulsarError::Invalid`].
275    pub fn pattern(pattern: impl Into<String>, subscription: impl Into<String>) -> Self {
276        Self {
277            topics: Topics::Pattern(pattern.into()),
278            ..Self::new(String::new(), subscription)
279        }
280    }
281
282    /// Sets the subscription type. Defaults to [`SubscriptionType::Shared`].
283    ///
284    /// Only the two types that dispatch to competing consumers carry a retry cap: the broker
285    /// counts a message's redeliveries there and nowhere else, so a registration that declares
286    /// `max_attempts(..)` over an [`Exclusive`](SubscriptionType::Exclusive) or a
287    /// [`Failover`](SubscriptionType::Failover) subscription refuses to start.
288    pub fn subscription_type(mut self, sub_type: SubscriptionType) -> Self {
289        self.sub_type = sub_type;
290        self
291    }
292
293    /// Redelivers messages that stay unacknowledged longer than `timeout`.
294    pub fn ack_timeout(mut self, timeout: Duration) -> Self {
295        self.ack_timeout = Some(timeout);
296        self
297    }
298
299    /// Caps how long a partial batch waits for more deliveries after its first one.
300    ///
301    /// Only a batch handler observes this: the client hands over one delivery at a time, so a
302    /// `batch(n)` registration on this subscription assembles its batches here, and a batch
303    /// closes when it holds `n` deliveries or when this has elapsed, whichever comes first.
304    /// Defaults to 10 ms; raising it trades latency for fuller batches on a sparse topic.
305    ///
306    /// # Examples
307    ///
308    /// ```
309    /// # mod demo {
310    /// use std::time::Duration;
311    ///
312    /// use ruststream_pulsar::prelude::*;
313    /// use serde::Deserialize;
314    ///
315    /// #[derive(Deserialize)]
316    /// struct Reading {
317    ///     value: f64,
318    /// }
319    ///
320    /// #[subscriber(
321    ///     PulsarSubscription::new("readings", "aggregator")
322    ///         .batch_wait(Duration::from_millis(250))
323    /// )]
324    /// async fn aggregate(readings: &[Reading]) -> HandlerOutcome {
325    ///     let total: f64 = readings.iter().map(|reading| reading.value).sum();
326    ///     tracing::info!(count = readings.len(), total, "readings settled");
327    ///     HandlerOutcome::ack()
328    /// }
329    ///
330    /// #[ruststream::app]
331    /// fn app() -> impl App {
332    ///     RustStream::new(AppInfo::new("readings", "0.1.0")).with_broker(
333    ///         PulsarBroker::new("pulsar://localhost:6650"),
334    ///         |b| {
335    ///             b.include(aggregate.batch(nonzero!(64)));
336    ///         },
337    ///     )
338    /// }
339    /// # }
340    /// # fn main() {}
341    /// ```
342    pub fn batch_wait(mut self, wait: Duration) -> Self {
343        self.batch_wait = wait;
344        self
345    }
346
347    /// The subscription name.
348    #[must_use]
349    pub fn subscription(&self) -> &str {
350        &self.subscription
351    }
352
353    /// Takes in what a registration declared about its retries. What the [`SubscriptionSource`]
354    /// impl does with the declaration, and what a bare topic name's descriptor is built with.
355    pub(crate) fn declaring(mut self, declaration: &RetryDeclaration) -> Self {
356        self.retry = declaration.clone();
357        self
358    }
359
360    /// The name the framework reports for a subscription on this descriptor: the topic when
361    /// there is exactly one, the subscription name otherwise, since a list and a pattern have no
362    /// single topic to name. The test harness's routing answer reads it too, so a subscription
363    /// is owed a publish under the name it is reported by.
364    pub(crate) fn source_name(&self) -> &str {
365        match &self.topics {
366            Topics::List(topics) if topics.len() == 1 => &topics[0],
367            _ => &self.subscription,
368        }
369    }
370
371    /// The consumer's dead-letter policy, named in a refusal by the subscription this
372    /// descriptor opens.
373    pub(crate) fn dead_letter_policy(&self) -> Result<Option<DeadLetterRoute>, PulsarError> {
374        let subject = format!("subscription '{}'", self.subscription);
375        let route = resolve_dead_letter(&self.retry, &subject)?;
376        if route.is_some() && !self.sub_type.counts_redeliveries() {
377            let sharing = self.sub_type;
378            return Err(PulsarError::Invalid(format!(
379                "{subject} declares a retry cap on a {sharing:?} subscription: Pulsar counts a \
380                 message's redeliveries only where a subscription dispatches to competing \
381                 consumers, so on this one the cap is never reached and a spent delivery circles \
382                 the subscription for ever. Declare the subscription \
383                 SubscriptionType::Shared or SubscriptionType::KeyShared, or drop the cap and the \
384                 dead-letter topic"
385            )));
386        }
387        Ok(route)
388    }
389
390    /// What this subscription adds to its channel in the generated document.
391    ///
392    /// The namespace and the persistence come out of the topic name the descriptor was built
393    /// with, which is the only place a consumer learns them. A subscription over several topics
394    /// reports them only when every topic agrees, and a pattern subscription reports nothing:
395    /// the specification's fields are single-valued, and a document that picks one topic's
396    /// namespace to stand for all of them describes a deployment that does not exist.
397    #[cfg(feature = "asyncapi")]
398    fn describe_channel(&self) -> Bindings {
399        let Topics::List(topics) = &self.topics else {
400            return Bindings::new();
401        };
402        let mut parsed = topics.iter().map(|topic| PulsarTopic::parse(topic));
403        let Some(Ok(first)) = parsed.next() else {
404            return Bindings::new();
405        };
406        for rest in parsed {
407            match rest {
408                Ok(topic)
409                    if topic.namespace() == first.namespace()
410                        && topic.persistence() == first.persistence() => {}
411                _ => return Bindings::new(),
412            }
413        }
414        topic_channel(&first)
415    }
416
417    /// What this subscription adds to its `receive` operation.
418    ///
419    /// The specification's `pulsar` operation object is empty, so this is an extension: the
420    /// subscription's name and type decide how competing consumers share the stream, and the
421    /// acknowledgement timeout decides when an unsettled delivery comes back, all of which a
422    /// reader of the document wants and no standard field carries.
423    #[cfg(feature = "asyncapi")]
424    fn describe_operation(&self) -> Bindings {
425        let (topics, topic_pattern) = match &self.topics {
426            Topics::List(topics) if topics.len() > 1 => (Some(topics.as_slice()), None),
427            Topics::List(_) => (None, None),
428            Topics::Pattern(pattern) => (None, Some(pattern.as_str())),
429        };
430        let body = PulsarOperation {
431            subscription: &self.subscription,
432            subscription_type: self.sub_type.as_str(),
433            ack_timeout_millis: self
434                .ack_timeout
435                .map(|timeout| u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)),
436            topics,
437            topic_pattern,
438        };
439        extension("x-ruststream-pulsar", &body)
440    }
441
442    pub(crate) fn display_topic(&self) -> String {
443        match &self.topics {
444            Topics::List(topics) => topics.join(","),
445            Topics::Pattern(pattern) => pattern.clone(),
446        }
447    }
448
449    /// Rejects descriptors that cannot form a subscription, before any I/O.
450    pub(crate) fn validate(&self) -> Result<(), PulsarError> {
451        if self.subscription.is_empty() {
452            return Err(PulsarError::Invalid(
453                "subscription name must be non-empty".into(),
454            ));
455        }
456        match &self.topics {
457            Topics::List(topics) => {
458                if topics.is_empty() || topics.iter().any(String::is_empty) {
459                    return Err(PulsarError::Invalid("topics must be non-empty".into()));
460                }
461                for topic in topics {
462                    let _ = PulsarTopic::parse(topic)?;
463                }
464            }
465            Topics::Pattern(pattern) => {
466                TopicPattern::parse(pattern)?;
467            }
468        }
469        self.dead_letter_policy()?;
470        Ok(())
471    }
472}
473
474/// The consumer's dead-letter policy, resolved from what a registration declared.
475///
476/// Pulsar's policy is a limit and a topic together - the client counts redeliveries and, past the
477/// limit, produces the message to that topic and acknowledges the original - so half a
478/// declaration is not a policy it can apply. A registration that declares one half refuses to
479/// start rather than running with a cap nobody enforces.
480///
481/// `subject` is what the refusal calls the registration: a descriptor is known by the
482/// subscription it names, a bare registration by the topic it opens.
483fn resolve_dead_letter(
484    retry: &RetryDeclaration,
485    subject: &str,
486) -> Result<Option<DeadLetterRoute>, PulsarError> {
487    match (retry.max_attempts(), retry.dead_letter()) {
488        (Some(attempts), Some(topic)) => {
489            let _ = PulsarTopic::parse(topic)?;
490            Ok(Some(DeadLetterRoute {
491                topic: topic.to_owned(),
492                max_deliveries: attempts.get(),
493            }))
494        }
495        (None, None) => Ok(None),
496        // The mount chain lets the two steps be written apart, so the pairing cannot be a
497        // compile error here; the startup refusal is what keeps a half declaration from looking
498        // applied.
499        (Some(_), None) => Err(PulsarError::Invalid(format!(
500            "{subject} declares max_attempts with no dead_letter: Pulsar applies a delivery \
501             limit only together with the topic a spent delivery moves to, so declare \
502             dead_letter(..) beside it or drop both"
503        ))),
504        (None, Some(topic)) => Err(PulsarError::Invalid(format!(
505            "{subject} declares dead_letter('{topic}') with no max_attempts: Pulsar moves a \
506             delivery to the dead-letter topic by counting redeliveries, so declare \
507             max_attempts(..) beside it or drop both"
508        ))),
509    }
510}
511
512/// What registrations mounted by a bare topic name declared about their retries.
513///
514/// A descriptor carries its own declaration into the consumer it opens. A bare name has nothing
515/// to carry it in: the framework hands the declaration to the broker in one call and asks it for
516/// the subscription in another, so the connected form holds it in between, under the topic both
517/// calls name. The two calls come in that order, one registration at a time, which is what makes
518/// the topic enough to find it by.
519#[derive(Debug, Default)]
520pub(crate) struct DeclaredRetries {
521    by_topic: Mutex<HashMap<String, RetryDeclaration>>,
522}
523
524impl DeclaredRetries {
525    /// Takes the declaration a registration made over the bare name `topic`.
526    ///
527    /// # Errors
528    ///
529    /// Returns [`DeclareRetryError::Broker`] for half a policy: the Pulsar client counts
530    /// redeliveries against a limit and produces the spent message to a topic, so neither half
531    /// applies without the other.
532    pub(crate) fn declare(
533        &self,
534        topic: &str,
535        declaration: &RetryDeclaration,
536    ) -> Result<(), DeclareRetryError> {
537        // The same resolution the descriptor's own consumer is built from, so the two cannot
538        // answer differently for one declaration. The refusal names the topic, because a bare
539        // registration carries no subscription name of its own.
540        resolve_dead_letter(declaration, &format!("topic '{topic}'"))
541            .map_err(|err| DeclareRetryError::Broker(Box::new(err)))?;
542        self.lock().insert(topic.to_owned(), declaration.clone());
543        Ok(())
544    }
545
546    /// The descriptor a bare `topic` opens: the broker's default `subscription` over it,
547    /// carrying what was declared for it.
548    pub(crate) fn subscription(&self, topic: &str, subscription: &str) -> PulsarSubscription {
549        let declared = self.lock().get(topic).cloned().unwrap_or_default();
550        PulsarSubscription::new(topic, subscription).declaring(&declared)
551    }
552
553    /// Nothing here panics while the map is held, so the lock cannot have been poisoned.
554    fn lock(&self) -> MutexGuard<'_, HashMap<String, RetryDeclaration>> {
555        self.by_topic
556            .lock()
557            .expect("the declarations are read and written without panicking")
558    }
559}
560
561// The manual path binds a `Handle` body to its source with `subscriber(source, body)`, which
562// takes the descriptor as it is, the way the `#[subscriber(..)]` attribute does.
563impl IntoSource for PulsarSubscription {
564    type Source = Self;
565
566    fn into_source(self) -> Self {
567        self
568    }
569}
570
571/// The Pulsar client moves a spent delivery itself: it counts the redeliveries of a message and,
572/// past the consumer's limit, produces it to the dead-letter topic and acknowledges the original.
573/// Nothing is republished from the service, on any of the three addressing forms, so
574/// `out_retry(..)` over this descriptor is a compile error and the registration's declaration
575/// reaches the consumer instead.
576impl SubscriptionSource<ConnectedPulsarBroker> for PulsarSubscription {
577    type Subscriber = PulsarSubscriber;
578    type Copies = BrokerMoves;
579
580    fn name(&self) -> &str {
581        self.source_name()
582    }
583
584    async fn subscribe(
585        self,
586        connected: &ConnectedPulsarBroker,
587    ) -> Result<PulsarSubscriber, PulsarError> {
588        connected.subscribe_descriptor(self).await
589    }
590
591    /// Records the declaration; the consumer is built from it in `subscribe`, where the
592    /// connection exists.
593    fn declare_retry(self, declaration: &RetryDeclaration) -> Self {
594        self.declaring(declaration)
595    }
596
597    #[cfg(feature = "asyncapi")]
598    fn channel_bindings(&self) -> Bindings {
599        self.describe_channel()
600    }
601
602    #[cfg(feature = "asyncapi")]
603    fn operation_bindings(&self) -> Bindings {
604        self.describe_operation()
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use ruststream::nonzero;
611
612    use super::*;
613
614    /// Takes the declaration in the way the runtime does.
615    fn declared(
616        subscription: PulsarSubscription,
617        declaration: &RetryDeclaration,
618    ) -> PulsarSubscription {
619        SubscriptionSource::declare_retry(subscription, declaration)
620    }
621
622    #[test]
623    fn empty_subscription_is_rejected_before_io() {
624        assert!(matches!(
625            PulsarSubscription::new("orders", "").validate(),
626            Err(PulsarError::Invalid(_))
627        ));
628    }
629
630    #[test]
631    fn malformed_topics_are_rejected_before_io() {
632        assert!(matches!(
633            PulsarSubscription::new("a/b", "workers").validate(),
634            Err(PulsarError::Invalid(_))
635        ));
636    }
637
638    /// Half a declaration is not a policy the client can apply, so it never reaches the broker.
639    #[test]
640    fn half_a_retry_declaration_is_rejected_before_io() {
641        let capped = declared(
642            PulsarSubscription::new("orders", "workers"),
643            &RetryDeclaration::new().with_max_attempts(nonzero!(3u32)),
644        );
645        let message = match capped.validate() {
646            Err(PulsarError::Invalid(message)) => message,
647            other => panic!("a cap with no destination must be refused, got {other:?}"),
648        };
649        assert!(message.contains("dead_letter"), "{message}");
650
651        let addressed = declared(
652            PulsarSubscription::new("orders", "workers"),
653            &RetryDeclaration::new().with_dead_letter("orders-dlq"),
654        );
655        let message = match addressed.validate() {
656            Err(PulsarError::Invalid(message)) => message,
657            other => panic!("a destination with no cap must be refused, got {other:?}"),
658        };
659        assert!(message.contains("max_attempts"), "{message}");
660    }
661
662    /// Both halves become the consumer's own policy, with the limit counted in deliveries.
663    #[test]
664    fn a_whole_declaration_becomes_the_consumers_policy() {
665        let policy = declared(
666            PulsarSubscription::new("orders", "workers"),
667            &RetryDeclaration::new()
668                .with_max_attempts(nonzero!(3u32))
669                .with_dead_letter("orders-dlq"),
670        );
671        assert_eq!(
672            policy.dead_letter_policy().expect("both halves declared"),
673            Some(DeadLetterRoute {
674                topic: "orders-dlq".to_owned(),
675                max_deliveries: 3,
676            })
677        );
678    }
679
680    /// A cap the broker would never reach is refused at startup rather than left to circle the
681    /// subscription: Pulsar keeps a redelivery count only where a subscription dispatches to
682    /// competing consumers, which the live suite drives against a server.
683    #[test]
684    fn a_cap_on_a_single_active_subscription_is_rejected_before_io() {
685        let whole = RetryDeclaration::new()
686            .with_max_attempts(nonzero!(3u32))
687            .with_dead_letter("orders-dlq");
688        for sharing in [SubscriptionType::Exclusive, SubscriptionType::Failover] {
689            let capped = declared(
690                PulsarSubscription::new("orders", "workers").subscription_type(sharing),
691                &whole,
692            );
693            let message = match capped.validate() {
694                Err(PulsarError::Invalid(message)) => message,
695                other => {
696                    panic!("a cap on a {sharing:?} subscription must be refused, got {other:?}")
697                }
698            };
699            assert!(message.contains(&format!("{sharing:?}")), "{message}");
700            assert!(message.contains("KeyShared"), "{message}");
701        }
702    }
703
704    /// The same subscription without a declaration is not the crate's business to refuse.
705    #[test]
706    fn a_single_active_subscription_without_a_cap_is_accepted() {
707        for sharing in [SubscriptionType::Exclusive, SubscriptionType::Failover] {
708            PulsarSubscription::new("orders", "workers")
709                .subscription_type(sharing)
710                .validate()
711                .expect("a subscription that declares no cap opens on any type");
712        }
713    }
714
715    #[test]
716    fn malformed_patterns_are_rejected_before_io() {
717        assert!(matches!(
718            PulsarSubscription::pattern("orders-(", "workers").validate(),
719            Err(PulsarError::Invalid(_))
720        ));
721    }
722}