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}