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}