Skip to main content

ruststream_gcp_pubsub/
subscription.rs

1//! [`GooglePubSub`]: the subscription descriptor.
2//!
3//! Pub/Sub separates the topic from the subscription, and the descriptor keeps both explicit:
4//! by default it names an existing subscription; `create_with_topic` opts into creating the
5//! subscription (and its topic) on subscribe, which is what local development against the
6//! emulator wants.
7
8use std::borrow::Cow;
9use std::collections::HashMap;
10use std::num::NonZeroU32;
11use std::ops::RangeInclusive;
12use std::sync::{Mutex, PoisonError};
13use std::time::Duration;
14
15use ruststream::runtime::IntoSource;
16use ruststream::{BrokerMoves, DeclareRetryError, FromName, RetryDeclaration, SubscriptionSource};
17
18use crate::broker::ConnectedPubSubBroker;
19use crate::error::PubSubError;
20use crate::subscriber::PubSubSubscriber;
21
22/// How long a partial batch waits for more deliveries before it goes out. One streaming-pull
23/// burst crosses the network in tens of milliseconds, so a deadline much shorter than this
24/// would cut most batches down to the first delivery that arrives.
25const DEFAULT_BATCH_WAIT: Duration = Duration::from_millis(50);
26
27/// The range Pub/Sub accepts for a dead-letter policy's `maxDeliveryAttempts`. A cap outside it
28/// is refused before the subscription opens, rather than at the API call that would reject it.
29const DELIVERY_ATTEMPTS: RangeInclusive<i32> = 5..=100;
30
31/// How long the client keeps extending the ack deadline of a delivery nothing has settled, which
32/// is the client's own default and the budget a delayed retry spends. Past it the client stops
33/// extending, the lease expires, and the subscription redelivers on its own.
34const DEFAULT_MAX_LEASE: Duration = Duration::from_hours(1);
35
36/// What one delivery may do before it has to be settled, read off the subscription that opened
37/// it: how many deliveries the dead-letter policy allows, and how long the client will keep a
38/// delivery leased.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub(crate) struct DeliveryLimits {
41    /// `None` where the subscription has no dead-letter policy, and Pub/Sub then counts nothing.
42    pub(crate) max_delivery_attempts: Option<i32>,
43    pub(crate) max_lease: Duration,
44}
45
46/// A subscription descriptor for one Pub/Sub subscription.
47///
48/// Implements [`SubscriptionSource`] for the connected broker, so it sits inline in the
49/// `#[subscriber(..)]` decorator, and the declaration a service ships is the one its tests run:
50///
51/// ```
52/// # mod demo {
53/// use std::time::Duration;
54///
55/// use ruststream_gcp_pubsub::prelude::*;
56/// use serde::Deserialize;
57///
58/// #[derive(Debug, Deserialize)]
59/// struct Order {
60///     id: u64,
61/// }
62///
63/// #[subscriber(
64///     GooglePubSub::new("orders-workers")
65///         .max_outstanding(1_000)
66///         .ack_extension(Duration::from_secs(60))
67/// )]
68/// async fn handle(order: &Order) -> HandlerOutcome {
69///     tracing::info!(order.id, "accepted");
70///     HandlerOutcome::ack()
71/// }
72///
73/// #[ruststream::app]
74/// fn app() -> impl App {
75///     RustStream::new(AppInfo::new("orders", "0.1.0"))
76///         .with_broker(PubSubBroker::new("my-project"), |b| {
77///             b.include(handle);
78///         })
79/// }
80/// # }
81/// # fn main() {}
82/// ```
83#[derive(Debug, Clone, PartialEq, Eq)]
84#[must_use]
85pub struct GooglePubSub {
86    name: String,
87    create_with_topic: Option<String>,
88    max_outstanding: Option<i64>,
89    ack_extension: Option<Duration>,
90    batch_wait: Duration,
91    /// What the registration declared with `max_attempts(..)`, taken in by `declare_retry`.
92    max_attempts: Option<NonZeroU32>,
93    /// What it declared with `dead_letter(..)`: the topic spent deliveries are published to.
94    dead_letter: Option<String>,
95    max_lease: Duration,
96}
97
98impl GooglePubSub {
99    /// Names an existing subscription (short name or full
100    /// `projects/{p}/subscriptions/{s}` resource name).
101    ///
102    /// The name is taken on trust: nothing is created, and the subscription is infrastructure
103    /// this crate does not own. Where it answers to nothing, the streaming pull is what says so,
104    /// and the stream yields [`PubSubError::Receive`] naming the subscription instead of
105    /// delivering.
106    pub fn new(name: impl Into<String>) -> Self {
107        Self {
108            name: name.into(),
109            create_with_topic: None,
110            max_outstanding: None,
111            ack_extension: None,
112            batch_wait: DEFAULT_BATCH_WAIT,
113            max_attempts: None,
114            dead_letter: None,
115            max_lease: DEFAULT_MAX_LEASE,
116        }
117    }
118
119    /// Creates the subscription bound to `topic` on subscribe when it does not exist yet (the
120    /// topic is created too). Meant for local development and tests against the emulator;
121    /// production subscriptions are usually managed as infrastructure.
122    ///
123    /// The subscription it creates enables message ordering, so a run of messages sharing an
124    /// ordering key reaches the handler in publish order. A subscription that already exists
125    /// keeps whatever its own configuration says: ordered delivery is a field of the
126    /// subscription resource, and the crate does not change it on a subscription it did not
127    /// create.
128    pub fn create_with_topic(mut self, topic: impl Into<String>) -> Self {
129        self.create_with_topic = Some(topic.into());
130        self
131    }
132
133    /// Flow control: how many received messages may be outstanding (unacked) at once. Defaults
134    /// to the client's 1000.
135    ///
136    /// The limit travels with the streaming pull and the service is what holds to it, pausing
137    /// the stream while this many deliveries are unsettled and resuming below it.
138    pub fn max_outstanding(mut self, messages: i64) -> Self {
139        self.max_outstanding = Some(messages);
140        self
141    }
142
143    /// How far each background ack-deadline extension reaches while a handler runs. The client
144    /// clamps it to the protocol's 10s..=600s range; defaults to 60s.
145    pub fn ack_extension(mut self, extension: Duration) -> Self {
146        self.ack_extension = Some(extension);
147        self
148    }
149
150    /// How long one delivery may stay unsettled before the client stops extending its ack
151    /// deadline. Defaults to the client's own hour.
152    ///
153    /// This is the budget a delayed retry spends: `HandlerOutcome::retry_after(delay)` holds the
154    /// delivery in the process for `delay`, and a delay longer than this is refused at the call,
155    /// because the subscription would redeliver before it elapsed.
156    ///
157    /// ```
158    /// # mod demo {
159    /// use std::time::Duration;
160    ///
161    /// use ruststream_gcp_pubsub::prelude::*;
162    /// use serde::Deserialize;
163    ///
164    /// #[derive(Debug, Deserialize)]
165    /// struct Invoice {
166    ///     id: u64,
167    ///     ready: bool,
168    /// }
169    ///
170    /// /// An invoice that is not ready yet comes back in ninety minutes, inside the lease.
171    /// #[subscriber(
172    ///     GooglePubSub::new("invoices-workers").max_lease(Duration::from_hours(2))
173    /// )]
174    /// async fn send(invoice: &Invoice) -> HandlerOutcome {
175    ///     if !invoice.ready {
176    ///         return HandlerOutcome::retry_after(Duration::from_mins(90));
177    ///     }
178    ///     tracing::info!(invoice.id, "sent");
179    ///     HandlerOutcome::ack()
180    /// }
181    ///
182    /// #[ruststream::app]
183    /// fn app() -> impl App {
184    ///     RustStream::new(AppInfo::new("invoices", "0.1.0"))
185    ///         .with_broker(PubSubBroker::new("my-project"), |b| {
186    ///             b.include(send);
187    ///         })
188    /// }
189    /// # }
190    /// # fn main() {}
191    /// ```
192    pub fn max_lease(mut self, lease: Duration) -> Self {
193        self.max_lease = lease;
194        self
195    }
196
197    /// How long a partial batch waits for more deliveries after its first one, for a handler
198    /// that takes a slice. Defaults to 50ms.
199    ///
200    /// How *large* a batch may be is not named here: that is the registration's
201    /// `batch(n)`, which reaches the subscription on its own. This is the other half - how
202    /// long the subscription is willing to wait for a batch that size, before handing over
203    /// what it has.
204    ///
205    /// ```
206    /// # mod demo {
207    /// use std::time::Duration;
208    ///
209    /// use ruststream_gcp_pubsub::prelude::*;
210    /// use serde::Deserialize;
211    ///
212    /// #[derive(Debug, Deserialize)]
213    /// struct Order {
214    ///     id: u64,
215    /// }
216    ///
217    /// #[subscriber(GooglePubSub::new("orders-workers").batch_wait(Duration::from_millis(200)))]
218    /// async fn index(orders: &[Order]) -> HandlerOutcome {
219    ///     let ids: Vec<u64> = orders.iter().map(|order| order.id).collect();
220    ///     tracing::info!(?ids, "indexed");
221    ///     HandlerOutcome::ack()
222    /// }
223    ///
224    /// #[ruststream::app]
225    /// fn app() -> impl App {
226    ///     RustStream::new(AppInfo::new("orders", "0.1.0"))
227    ///         .with_broker(PubSubBroker::new("my-project"), |b| {
228    ///             // Up to 100 orders per call, or whatever arrived within 200ms of the first.
229    ///             b.include(index.batch(nonzero!(100)));
230    ///         })
231    /// }
232    /// # }
233    /// # fn main() {}
234    /// ```
235    pub fn batch_wait(mut self, wait: Duration) -> Self {
236        self.batch_wait = wait;
237        self
238    }
239
240    /// The subscription name this descriptor resolves.
241    #[must_use]
242    pub fn subscription(&self) -> &str {
243        &self.name
244    }
245
246    pub(crate) fn create_topic_ref(&self) -> Option<&str> {
247        self.create_with_topic.as_deref()
248    }
249
250    pub(crate) fn max_outstanding_value(&self) -> Option<i64> {
251        self.max_outstanding
252    }
253
254    pub(crate) fn ack_extension_value(&self) -> Option<Duration> {
255        self.ack_extension
256    }
257
258    pub(crate) fn batch_wait_value(&self) -> Duration {
259        self.batch_wait
260    }
261
262    /// What one delivery of this subscription may do before it has to be settled.
263    pub(crate) fn limits(&self) -> DeliveryLimits {
264        DeliveryLimits {
265            max_delivery_attempts: self.dead_letter_policy().map(|(_, attempts)| attempts),
266            max_lease: self.max_lease,
267        }
268    }
269
270    /// The declared cap in the API's own type. A count past `i32::MAX` saturates onto a value
271    /// [`validate`](Self::validate) refuses, so a cap too large to express is rejected rather
272    /// than quietly reduced.
273    fn declared_attempts(&self) -> Option<i32> {
274        self.max_attempts
275            .map(|attempts| i32::try_from(attempts.get()).unwrap_or(i32::MAX))
276    }
277
278    /// The dead-letter policy this subscription opens with: the topic spent deliveries go to and
279    /// how many deliveries one message gets. `None` where the registration declared neither.
280    pub(crate) fn dead_letter_policy(&self) -> Option<(&str, i32)> {
281        Some((self.dead_letter.as_deref()?, self.declared_attempts()?))
282    }
283
284    /// Takes in a registration's cap and dead-letter destination, which become the
285    /// subscription's dead-letter policy when it opens. The two `SubscriptionSource` impls and
286    /// the by-name ledger below all go through here, so a declaration means the same thing
287    /// whichever way the registration named its subscription.
288    fn take_declaration(mut self, declaration: &RetryDeclaration) -> Self {
289        self.max_attempts = declaration.max_attempts();
290        self.dead_letter = declaration.dead_letter().map(ToOwned::to_owned);
291        self
292    }
293
294    /// Rejects descriptors that cannot form a subscription, before any I/O.
295    pub(crate) fn validate(&self) -> Result<(), PubSubError> {
296        if self.name.is_empty() {
297            return Err(PubSubError::InvalidDescriptor(
298                "subscription name must be non-empty".into(),
299            ));
300        }
301        if self.create_with_topic.as_deref() == Some("") {
302            return Err(PubSubError::InvalidDescriptor(
303                "topic name must be non-empty".into(),
304            ));
305        }
306        self.validate_declaration()
307    }
308
309    /// Holds the registration's declaration to what a Pub/Sub dead-letter policy can express.
310    ///
311    /// A dead-letter policy is one resource field carrying both halves, so a subscription cannot
312    /// honour half a declaration: a cap alone would leave a spent delivery circulating, and a
313    /// destination alone has no attempt count to fire on. Saying so at startup is the earliest
314    /// the crate can - the mount site's declaration reaches the descriptor as data, so nothing
315    /// here is a type the compiler could reject.
316    fn validate_declaration(&self) -> Result<(), PubSubError> {
317        match (self.declared_attempts(), self.dead_letter.as_deref()) {
318            (Some(attempts), Some(topic)) => {
319                if topic.is_empty() {
320                    return Err(PubSubError::InvalidDescriptor(
321                        "dead-letter topic must be non-empty".into(),
322                    ));
323                }
324                if !DELIVERY_ATTEMPTS.contains(&attempts) {
325                    return Err(PubSubError::InvalidDescriptor(format!(
326                        "max_attempts({attempts}) is outside the {}..={} a Pub/Sub dead-letter \
327                         policy accepts",
328                        DELIVERY_ATTEMPTS.start(),
329                        DELIVERY_ATTEMPTS.end(),
330                    )));
331                }
332                Ok(())
333            }
334            (Some(_), None) => Err(PubSubError::InvalidDescriptor(format!(
335                "subscription '{}' declares max_attempts without dead_letter; a Pub/Sub \
336                 dead-letter policy needs the topic too",
337                self.name,
338            ))),
339            (None, Some(_)) => Err(PubSubError::InvalidDescriptor(format!(
340                "subscription '{}' declares dead_letter without max_attempts; a Pub/Sub \
341                 dead-letter policy needs the attempt count too",
342                self.name,
343            ))),
344            (None, None) => Ok(()),
345        }
346    }
347}
348
349/// A subscription is identified by its name and nothing else, every other setting having a
350/// default, so the mount site may supply the name instead of the declaration:
351/// `#[subscriber(GooglePubSub)]` on the handler and `.name("orders-workers")` where it is mounted.
352///
353/// # Examples
354///
355/// ```
356/// # mod demo {
357/// use ruststream_gcp_pubsub::prelude::*;
358/// use serde::Deserialize;
359///
360/// #[derive(Debug, Deserialize)]
361/// struct Order {
362///     id: u64,
363/// }
364///
365/// #[subscriber(GooglePubSub)]
366/// async fn handle(order: &Order) -> HandlerOutcome {
367///     tracing::info!(order.id, "accepted");
368///     HandlerOutcome::ack()
369/// }
370///
371/// #[ruststream::app]
372/// fn app() -> impl App {
373///     RustStream::new(AppInfo::new("orders", "0.1.0"))
374///         .with_broker(PubSubBroker::new("my-project"), |b| {
375///             // The deployment names the subscription; every other setting is the default.
376///             b.include(handle.name("orders-workers"));
377///         })
378/// }
379/// # }
380/// # fn main() {}
381/// ```
382impl FromName for GooglePubSub {
383    fn from_name(name: impl Into<Cow<'static, str>>) -> Self {
384        Self::new(name.into().into_owned())
385    }
386}
387
388// The manual path binds a `Handle` body to its source with `subscriber(source, body)`, which
389// takes the descriptor as it is, the way the `#[subscriber(..)]` attribute does.
390impl IntoSource for GooglePubSub {
391    type Source = Self;
392
393    fn into_source(self) -> Self {
394        self
395    }
396}
397
398/// A Pub/Sub subscription moves a spent delivery itself, so the copy path is
399/// [`BrokerMoves`]: the subscription's dead-letter policy is the mechanism, and nothing is
400/// published from the service to retry a message. `.out_retry(..)` over this descriptor is
401/// therefore a compile error, and the error names it.
402///
403/// # Examples
404///
405/// `.max_attempts(n).dead_letter(topic)` at the mount site becomes the subscription's own
406/// dead-letter policy, written when the service starts:
407///
408/// ```
409/// # mod demo {
410/// use std::time::Duration;
411///
412/// use ruststream_gcp_pubsub::prelude::*;
413/// use serde::Deserialize;
414///
415/// #[derive(Debug, Deserialize)]
416/// struct Payment {
417///     id: u64,
418///     settled: bool,
419/// }
420///
421/// #[subscriber(GooglePubSub::new("payments-workers").batch_wait(Duration::from_millis(200)))]
422/// async fn reconcile(payments: &[Payment]) -> HandlerOutcome {
423///     if payments.iter().any(|payment| !payment.settled) {
424///         return HandlerOutcome::retry_after(Duration::from_secs(5));
425///     }
426///     tracing::info!(count = payments.len(), "settled");
427///     HandlerOutcome::ack()
428/// }
429///
430/// #[ruststream::app]
431/// fn app() -> impl App {
432///     RustStream::new(AppInfo::new("payments", "0.1.0")).with_broker(
433///         PubSubBroker::new("my-project"),
434///         |b| {
435///             // Pub/Sub gives one payment five deliveries, then publishes it to `payments-dead`.
436///             b.include(reconcile.batch(nonzero!(50)))
437///                 .max_attempts(nonzero!(5u32))
438///                 .dead_letter("payments-dead");
439///         },
440///     )
441/// }
442/// # }
443/// # fn main() {}
444/// ```
445impl SubscriptionSource<ConnectedPubSubBroker> for GooglePubSub {
446    type Subscriber = PubSubSubscriber;
447    type Copies = BrokerMoves;
448
449    fn name(&self) -> &str {
450        self.subscription()
451    }
452
453    async fn subscribe(
454        self,
455        connected: &ConnectedPubSubBroker,
456    ) -> Result<PubSubSubscriber, PubSubError> {
457        connected.subscribe_descriptor(self).await
458    }
459
460    /// Takes in the registration's cap and dead-letter destination, which become the
461    /// subscription's dead-letter policy when [`subscribe`](Self::subscribe) opens it: the topic
462    /// is the policy's `deadLetterTopic` and the cap its `maxDeliveryAttempts`.
463    ///
464    /// Only recorded here. The declaration turns into topology where the connection exists, and
465    /// a half declaration is refused there rather than opening a subscription that honours
466    /// neither half.
467    fn declare_retry(self, declaration: &RetryDeclaration) -> Self {
468        self.take_declaration(declaration)
469    }
470}
471
472/// What the bare-name registrations of one connection declared about their retries.
473///
474/// A bare name carries no descriptor to declare on, so the broker takes the declaration through
475/// [`Subscribe::declare_retry`](ruststream::Subscribe::declare_retry) and keeps it here until
476/// the subscription of that name opens. The connection holds one over either transport, so
477/// `#[subscriber("orders-workers")]` gets the dead-letter policy the mount site declared in a test
478/// as in production.
479#[derive(Debug, Default)]
480pub(crate) struct DeclaredRetries(Mutex<HashMap<String, RetryDeclaration>>);
481
482impl DeclaredRetries {
483    /// Takes what a registration declared for the subscription `name` opens.
484    ///
485    /// Refuses what a Pub/Sub dead-letter policy cannot express - half a declaration, a cap
486    /// outside the range the service accepts - on the same check the descriptor runs, and
487    /// refuses a second registration on the same subscription that declares something else: one
488    /// subscription carries one policy, and the last writer winning would silently drop what the
489    /// other registration asked for.
490    pub(crate) fn take(
491        &self,
492        name: &str,
493        declaration: &RetryDeclaration,
494    ) -> Result<(), DeclareRetryError> {
495        GooglePubSub::new(name)
496            .take_declaration(declaration)
497            .validate()
498            .map_err(refusal)?;
499        let conflicts = {
500            let mut taken = self.0.lock().unwrap_or_else(PoisonError::into_inner);
501            match taken.get(name) {
502                Some(already) if already != declaration => true,
503                _ => {
504                    taken.insert(name.to_owned(), declaration.clone());
505                    false
506                }
507            }
508        };
509        if conflicts {
510            return Err(refusal(PubSubError::InvalidDescriptor(format!(
511                "subscription '{name}' is mounted twice with different retry \
512                 declarations, and one subscription carries one dead-letter policy",
513            ))));
514        }
515        Ok(())
516    }
517
518    /// The descriptor the bare name `name` opens: the subscription it names, carrying what the
519    /// registration declared for it.
520    pub(crate) fn source(&self, name: &str) -> GooglePubSub {
521        let source = GooglePubSub::new(name);
522        match self
523            .0
524            .lock()
525            .unwrap_or_else(PoisonError::into_inner)
526            .get(name)
527        {
528            Some(declaration) => source.take_declaration(declaration),
529            None => source,
530        }
531    }
532}
533
534/// A startup refusal, with the crate's own error as its cause: the runtime prints the reason on
535/// one line and fails the registration that declared it.
536fn refusal(err: PubSubError) -> DeclareRetryError {
537    DeclareRetryError::Broker(Box::new(err))
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn empty_subscription_name_is_rejected_before_io() {
546        assert!(matches!(
547            GooglePubSub::new("").validate(),
548            Err(PubSubError::InvalidDescriptor(_))
549        ));
550    }
551
552    #[test]
553    fn empty_topic_name_is_rejected_before_io() {
554        assert!(matches!(
555            GooglePubSub::new("s").create_with_topic("").validate(),
556            Err(PubSubError::InvalidDescriptor(_))
557        ));
558    }
559
560    /// What a registration declaring `attempts` and `destination` states at its mount site.
561    fn declaration(attempts: Option<u32>, destination: Option<&str>) -> RetryDeclaration {
562        let mut declaration = RetryDeclaration::new();
563        if let Some(attempts) = attempts {
564            declaration =
565                declaration.with_max_attempts(NonZeroU32::new(attempts).expect("non-zero"));
566        }
567        if let Some(destination) = destination {
568            declaration = declaration.with_dead_letter(destination.to_owned());
569        }
570        declaration
571    }
572
573    /// Builds the descriptor a registration declaring `attempts` and `destination` produces.
574    fn declared(attempts: Option<u32>, destination: Option<&str>) -> GooglePubSub {
575        SubscriptionSource::<ConnectedPubSubBroker>::declare_retry(
576            GooglePubSub::new("orders-workers"),
577            &declaration(attempts, destination),
578        )
579    }
580
581    /// Both halves are one resource field, so the descriptor carries them as one policy.
582    #[test]
583    fn a_full_declaration_becomes_the_subscriptions_dead_letter_policy() {
584        let source = declared(Some(5), Some("orders-dead"));
585        assert_eq!(source.dead_letter_policy(), Some(("orders-dead", 5)));
586    }
587
588    /// A registration that declared nothing leaves the subscription's own topology alone.
589    #[test]
590    fn an_empty_declaration_leaves_the_subscription_untouched() {
591        let source = declared(None, None);
592        assert_eq!(source.dead_letter_policy(), None);
593        assert!(source.validate().is_ok());
594    }
595
596    /// Half a declaration cannot be honoured, and the subscription says so before it opens
597    /// rather than running without the cap the registration asked for.
598    #[test]
599    fn half_a_declaration_is_refused_before_io() {
600        for (attempts, destination) in [(Some(5), None), (None, Some("orders-dead"))] {
601            let source = declared(attempts, destination);
602            assert!(
603                matches!(source.validate(), Err(PubSubError::InvalidDescriptor(_))),
604                "declaring {attempts:?} / {destination:?} must be refused",
605            );
606        }
607    }
608
609    /// Pub/Sub bounds `maxDeliveryAttempts`, so a cap outside it is named here and not by the
610    /// admin call that would reject it.
611    #[test]
612    fn a_cap_outside_the_services_range_is_refused_before_io() {
613        for attempts in [1, 4, 101, 1_000] {
614            let source = declared(Some(attempts), Some("orders-dead"));
615            assert!(
616                matches!(source.validate(), Err(PubSubError::InvalidDescriptor(_))),
617                "max_attempts({attempts}) must be refused",
618            );
619        }
620        for attempts in [5, 100] {
621            let source = declared(Some(attempts), Some("orders-dead"));
622            assert!(
623                source.validate().is_ok(),
624                "max_attempts({attempts}) must be accepted",
625            );
626        }
627    }
628
629    /// A cap too large for the wire type saturates onto a value the range check refuses, so it
630    /// is rejected rather than quietly reduced to something the service accepts.
631    #[test]
632    fn a_cap_past_the_wire_type_is_refused_rather_than_reduced() {
633        let source = declared(Some(u32::MAX), Some("orders-dead"));
634        assert_eq!(source.declared_attempts(), Some(i32::MAX));
635        assert!(matches!(
636            source.validate(),
637            Err(PubSubError::InvalidDescriptor(_))
638        ));
639    }
640
641    /// A dead-letter topic is a resource name, so an empty one is refused with the rest.
642    #[test]
643    fn an_empty_dead_letter_topic_is_refused_before_io() {
644        let source = declared(Some(5), Some(""));
645        assert!(matches!(
646            source.validate(),
647            Err(PubSubError::InvalidDescriptor(_))
648        ));
649    }
650
651    /// A bare name has no descriptor to hold the declaration, so the broker's ledger holds it
652    /// and the subscription that name opens carries the policy.
653    #[test]
654    fn a_declaration_for_a_bare_name_reaches_the_subscription_it_opens() {
655        let taken = DeclaredRetries::default();
656
657        taken
658            .take("orders-workers", &declaration(Some(5), Some("orders-dead")))
659            .expect("a full declaration is one a dead-letter policy carries");
660
661        assert_eq!(
662            taken.source("orders-workers").dead_letter_policy(),
663            Some(("orders-dead", 5)),
664        );
665    }
666
667    /// A name nothing declared for opens the subscription as it stands.
668    #[test]
669    fn a_bare_name_with_no_declaration_opens_the_subscription_untouched() {
670        let taken = DeclaredRetries::default();
671
672        taken
673            .take("orders-workers", &RetryDeclaration::new())
674            .expect("a registration may declare nothing");
675
676        assert_eq!(
677            taken.source("orders-workers"),
678            GooglePubSub::new("orders-workers")
679        );
680    }
681
682    /// The ledger holds a bare name to the same policy the descriptor is held to, and the
683    /// refusal carries the crate's own reason so the startup line says what to fix.
684    #[test]
685    fn a_bare_name_is_refused_what_a_dead_letter_policy_cannot_express() {
686        let taken = DeclaredRetries::default();
687
688        for (attempts, destination) in [
689            (Some(5), None),
690            (None, Some("orders-dead")),
691            (Some(3), Some("orders-dead")),
692        ] {
693            let err = taken
694                .take("orders-workers", &declaration(attempts, destination))
695                .expect_err("the declaration must be refused");
696            assert!(
697                matches!(err, DeclareRetryError::Broker(_)),
698                "declaring {attempts:?} / {destination:?} must refuse as a broker rejection",
699            );
700        }
701    }
702
703    /// One subscription carries one dead-letter policy, so a second registration declaring
704    /// something else for the same name is refused rather than silently overwriting the first.
705    #[test]
706    fn one_subscription_declared_twice_differently_is_refused() {
707        let taken = DeclaredRetries::default();
708        taken
709            .take("orders-workers", &declaration(Some(5), Some("orders-dead")))
710            .expect("the first declaration is taken");
711
712        let err = taken
713            .take(
714                "orders-workers",
715                &declaration(Some(10), Some("orders-dead")),
716            )
717            .expect_err("a contradicting declaration must be refused");
718
719        assert!(matches!(err, DeclareRetryError::Broker(_)));
720        assert_eq!(
721            taken.source("orders-workers").dead_letter_policy(),
722            Some(("orders-dead", 5)),
723            "the refusal leaves the declaration already taken alone",
724        );
725    }
726
727    /// Two registrations that declare the same thing are no contradiction: the subscription
728    /// opens with the policy both asked for.
729    #[test]
730    fn one_subscription_declared_twice_alike_is_taken_once() {
731        let taken = DeclaredRetries::default();
732        let declared = declaration(Some(5), Some("orders-dead"));
733
734        taken.take("orders-workers", &declared).expect("the first");
735        taken.take("orders-workers", &declared).expect("the second");
736
737        assert_eq!(
738            taken.source("orders-workers").dead_letter_policy(),
739            Some(("orders-dead", 5)),
740        );
741    }
742}