Skip to main content

ruststream_sqs_sns/
queue.rs

1//! [`SqsQueue`]: the subscription descriptor, and [`SqsSubscription`]: its mount-site spelling.
2//!
3//! The polling parameters that decide cost and latency are explicit: `wait` (long polling) and
4//! `visibility` (the redelivery timeout the crate keeps extending while a handler holds a
5//! message). How many messages one receive call asks for is not among them - that is the batch
6//! size, which a batch handler names at the mount site with `batch(n)` and the subscriber maps
7//! onto `MaxNumberOfMessages`.
8
9use std::borrow::Cow;
10use std::num::NonZeroU32;
11use std::time::Duration;
12
13#[cfg(feature = "asyncapi")]
14use ruststream::asyncapi::{Binding, Bindings};
15use ruststream::runtime::{Declared, SubscriberBuilder, SubscriberSettings};
16use ruststream::{BrokerMoves, FromName, RetryDeclaration, SubscriptionSource};
17#[cfg(feature = "asyncapi")]
18use serde::Serialize;
19
20use crate::broker::ConnectedSqsBroker;
21use crate::error::SqsError;
22#[cfg(feature = "asyncapi")]
23use crate::publisher::is_fifo;
24use crate::subscriber::SqsSubscriber;
25
26/// The protocol cap on long polling.
27const MAX_WAIT: Duration = Duration::from_secs(20);
28
29/// `duration` in the whole seconds SQS counts a timeout in, rounded up: rounding down would make
30/// a message visible before the time it was asked to stay hidden for.
31pub(crate) const fn whole_seconds(duration: Duration) -> u64 {
32    if duration.subsec_nanos() > 0 {
33        duration.as_secs() + 1
34    } else {
35        duration.as_secs()
36    }
37}
38
39/// The registration's retry declaration in the queue's own vocabulary.
40///
41/// A redrive policy is one setting with two halves: after `max_receive_count` receives SQS moves
42/// the delivery to `dead_letter` on its own. Holding them in one value is what keeps half a
43/// policy off the queue.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub(crate) struct Redrive {
46    pub(crate) max_receive_count: NonZeroU32,
47    pub(crate) dead_letter: String,
48}
49
50/// A subscription descriptor for one SQS queue.
51///
52/// Accepts a queue URL or a queue name (resolved through `GetQueueUrl` on subscribe).
53/// Implements [`SubscriptionSource`], so it can sit inline in the `#[subscriber(..)]`
54/// decorator:
55///
56/// ```
57/// use std::time::Duration;
58///
59/// use ruststream_sqs_sns::prelude::*;
60/// # #[derive(Deserialized)]
61/// # struct Order<'a>(&'a [u8]);
62///
63/// #[subscriber(
64///     SqsQueue::new("orders")
65///         .wait(Duration::from_secs(20))
66///         .visibility(Duration::from_secs(30))
67/// )]
68/// async fn handle(order: &Order<'_>) -> HandlerOutcome {
69///     if order.0.is_empty() {
70///         return HandlerOutcome::drop();
71///     }
72///     HandlerOutcome::ack()
73/// }
74///
75/// #[app]
76/// fn service() -> impl App {
77///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(SqsBroker::new(), |b| {
78///         b.include(handle);
79///     })
80/// }
81/// ```
82///
83/// The same options are also reachable at the mount site through [`SqsSubscription`], which is
84/// where they go when the registration names a batch size first.
85///
86/// `max_attempts(n)` and `dead_letter(name)` right after `include` are one setting here, the
87/// queue's redrive policy: the subscription writes both onto the queue when it opens, and SQS
88/// counts the receives and moves a spent delivery to the dead-letter queue itself. Half a policy
89/// is [`SqsError::IncompleteRedrive`] at startup.
90///
91/// ```
92/// use ruststream_sqs_sns::prelude::*;
93/// use serde::Deserialize;
94///
95/// #[derive(Deserialize)]
96/// struct Invoice {
97///     id: u64,
98/// }
99///
100/// #[subscriber(SqsQueue::new("invoices"))]
101/// async fn settle(invoice: &Invoice) -> HandlerOutcome {
102///     if invoice.id == 0 {
103///         return HandlerOutcome::retry();
104///     }
105///     HandlerOutcome::ack()
106/// }
107///
108/// #[app]
109/// fn service() -> impl App {
110///     RustStream::new(AppInfo::new("invoices", "0.1.0")).with_broker(SqsBroker::new(), |b| {
111///         b.include(settle)
112///             .max_attempts(nonzero!(3u32))
113///             .dead_letter("invoices-dead");
114///     })
115/// }
116/// ```
117#[derive(Debug, Clone, PartialEq, Eq)]
118#[must_use]
119pub struct SqsQueue {
120    queue: String,
121    wait: Duration,
122    visibility: Option<Duration>,
123    create_if_missing: bool,
124    max_attempts: Option<NonZeroU32>,
125    dead_letter: Option<Cow<'static, str>>,
126}
127
128impl SqsQueue {
129    /// Names the queue by URL (`https://sqs...`) or by name.
130    pub fn new(queue: impl Into<String>) -> Self {
131        Self {
132            queue: queue.into(),
133            wait: MAX_WAIT,
134            visibility: None,
135            create_if_missing: false,
136            max_attempts: None,
137            dead_letter: None,
138        }
139    }
140
141    /// Long-polling wait per receive call. Defaults to the protocol maximum of 20 seconds;
142    /// values above it are rejected before any I/O.
143    pub fn wait(mut self, wait: Duration) -> Self {
144        self.wait = wait;
145        self
146    }
147
148    /// The visibility timeout requested per receive; the crate extends it in the background
149    /// while a handler holds the message. Defaults to the queue's configured timeout.
150    ///
151    /// SQS takes whole seconds from one second to twelve hours. A value outside that range is
152    /// rejected before any I/O, and a fraction of a second rounds up to the next whole second.
153    pub fn visibility(mut self, visibility: Duration) -> Self {
154        self.visibility = Some(visibility);
155        self
156    }
157
158    /// Creates the queue on subscribe when it does not exist yet (a name ending in `.fifo`
159    /// creates a FIFO queue with content-based deduplication). Meant for local development and
160    /// tests; production queues are usually managed as infrastructure.
161    pub fn create_if_missing(mut self) -> Self {
162        self.create_if_missing = true;
163        self
164    }
165
166    /// The queue URL or name this descriptor resolves.
167    #[must_use]
168    pub fn queue(&self) -> &str {
169        &self.queue
170    }
171
172    pub(crate) fn wait_value(&self) -> Duration {
173        self.wait
174    }
175
176    /// The visibility timeout as SQS is asked for it, in whole seconds.
177    pub(crate) fn visibility_value(&self) -> Option<Duration> {
178        self.visibility
179            .map(|visibility| Duration::from_secs(whole_seconds(visibility)))
180    }
181
182    pub(crate) fn create_value(&self) -> bool {
183        self.create_if_missing
184    }
185
186    /// Records what a registration declared, in the descriptor's own fields.
187    ///
188    /// Every mount path goes through this one mapping: the descriptor's own `declare_retry`, and
189    /// the bare-name path, where the broker maps the same declaration onto the queue the name
190    /// opens. A single mapping is what keeps the two spellings from drifting apart.
191    pub(crate) fn with_declaration(mut self, declaration: &RetryDeclaration) -> Self {
192        self.max_attempts = declaration.max_attempts();
193        self.dead_letter = declaration
194            .dead_letter()
195            .map(|destination| Cow::Owned(destination.to_owned()));
196        self
197    }
198
199    /// Puts a redrive policy the broker already holds back onto a descriptor.
200    ///
201    /// The bare-name path needs it: the declaration reaches the broker before there is a
202    /// descriptor to take it, and `subscribe` builds one from the name alone.
203    pub(crate) fn with_redrive(mut self, redrive: Option<Redrive>) -> Self {
204        if let Some(redrive) = redrive {
205            self.max_attempts = Some(redrive.max_receive_count);
206            self.dead_letter = Some(Cow::Owned(redrive.dead_letter));
207        }
208        self
209    }
210
211    /// The redrive policy the registration declared, or nothing where it declared nothing.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`SqsError::IncompleteRedrive`] when only one half was declared.
216    pub(crate) fn redrive(&self) -> Result<Option<Redrive>, SqsError> {
217        match (self.max_attempts, self.dead_letter.as_deref()) {
218            (Some(max_receive_count), Some(dead_letter)) => Ok(Some(Redrive {
219                max_receive_count,
220                dead_letter: dead_letter.to_owned(),
221            })),
222            (None, None) => Ok(None),
223            (Some(_), None) => Err(SqsError::IncompleteRedrive {
224                queue: self.queue.clone(),
225                declared: "max_attempts(..)",
226                missing: "dead_letter(..)",
227            }),
228            (None, Some(_)) => Err(SqsError::IncompleteRedrive {
229                queue: self.queue.clone(),
230                declared: "dead_letter(..)",
231                missing: "max_attempts(..)",
232            }),
233        }
234    }
235
236    /// What this subscription adds to its channel in the generated `AsyncAPI` document.
237    ///
238    /// Everything here is read off the descriptor, because the document is built before anything
239    /// connects: the queue's name, whether it is FIFO (the `.fifo` suffix says so) and the
240    /// polling settings the descriptor names. A queue's ARN, its retention period and the
241    /// timeout it carries when the descriptor names none are the service's to tell, and stay
242    /// out.
243    #[cfg(feature = "asyncapi")]
244    fn channel_binding(&self) -> Bindings {
245        let body = SqsChannel {
246            queue: QueueObject {
247                name: &self.queue,
248                fifo_queue: is_fifo(&self.queue),
249                visibility_timeout: self
250                    .visibility_value()
251                    .map(|visibility| visibility.as_secs()),
252                receive_message_wait_time: self.wait.as_secs(),
253            },
254        };
255        // A binding that fails to build is a binding the document goes without: a broker never
256        // holds up a service over a description of itself.
257        Binding::new("sqs", SQS_BINDING_VERSION, &body)
258            .map(|binding| Bindings::new().with(binding))
259            .unwrap_or_default()
260    }
261
262    /// Rejects descriptors that cannot form a subscription, before any I/O.
263    pub(crate) fn validate(&self) -> Result<(), SqsError> {
264        if self.queue.is_empty() {
265            return Err(SqsError::InvalidQueue("queue must be non-empty".into()));
266        }
267        if self.wait > MAX_WAIT {
268            return Err(SqsError::InvalidQueue(
269                "wait exceeds the 20 second long-polling cap".into(),
270            ));
271        }
272        if let Some(visibility) = self.visibility
273            && (visibility < Duration::from_secs(1) || visibility > Duration::from_hours(12))
274        {
275            return Err(SqsError::InvalidQueue(
276                "visibility must be within 1s..=12h".into(),
277            ));
278        }
279        // The declaration arrives as data at startup, so its two halves are checked here rather
280        // than at the mount site: what the chain offers is the core's, one step per half, and
281        // the pairing a redrive policy needs is this broker's alone.
282        self.redrive()?;
283        Ok(())
284    }
285}
286
287/// The binding version this crate writes for the `sqs` protocol, on both sides of a channel.
288#[cfg(feature = "asyncapi")]
289pub(crate) const SQS_BINDING_VERSION: &str = "0.3.0";
290
291/// The `sqs` channel binding: what a reader of the document learns about the queue behind this
292/// channel.
293///
294/// The specification's `deadLetterQueue` and the queue's `redrivePolicy` are absent, and not for
295/// want of the values: the framework reads a descriptor's bindings where the handler is included,
296/// which is before the registration declares its cap and its destination. It reports the
297/// declaration itself on the operation instead, and the dead-letter queue as a channel the
298/// registration sends to.
299#[cfg(feature = "asyncapi")]
300#[derive(Serialize)]
301struct SqsChannel<'a> {
302    queue: QueueObject<'a>,
303}
304
305/// The specification's Queue object. Only the fields a descriptor can answer without a
306/// connection are here; a queue's ARN, its retention period and its access policy are not among
307/// them, and the last of those is infrastructure rather than a description of a service.
308#[cfg(feature = "asyncapi")]
309#[derive(Serialize)]
310struct QueueObject<'a> {
311    name: &'a str,
312    #[serde(rename = "fifoQueue")]
313    fifo_queue: bool,
314    #[serde(rename = "visibilityTimeout", skip_serializing_if = "Option::is_none")]
315    visibility_timeout: Option<u64>,
316    #[serde(rename = "receiveMessageWaitTime")]
317    receive_message_wait_time: u64,
318}
319
320/// A queue is named and nothing more, so a definition may fix the kind and leave the name to the
321/// mount site: `#[subscriber(SqsQueue)]` on the handler, `.name("orders")` where it is included.
322/// Every polling option keeps its default there, and the mount-site steps of [`SqsSubscription`]
323/// change them.
324impl FromName for SqsQueue {
325    fn from_name(name: impl Into<Cow<'static, str>>) -> Self {
326        Self::new(name.into())
327    }
328}
329
330impl SubscriptionSource<ConnectedSqsBroker> for SqsQueue {
331    type Subscriber = SqsSubscriber;
332    // The queue carries a spent delivery away itself. Its redrive policy counts the receives and
333    // moves the message to the dead-letter queue once they run out, so this process publishes no
334    // copy and `.out_retry(..)` does not compile on this descriptor.
335    type Copies = BrokerMoves;
336
337    fn name(&self) -> &str {
338        self.queue()
339    }
340
341    async fn subscribe(self, connected: &ConnectedSqsBroker) -> Result<SqsSubscriber, SqsError> {
342        connected.subscribe_queue(self).await
343    }
344
345    fn declare_retry(self, declaration: &RetryDeclaration) -> Self {
346        // Recorded only: the redrive policy is written on the queue, and there is no connection
347        // here to write it through. `subscribe` applies it.
348        self.with_declaration(declaration)
349    }
350
351    #[cfg(feature = "asyncapi")]
352    fn channel_bindings(&self) -> Bindings {
353        self.channel_binding()
354    }
355}
356
357/// The queue options in mount-site spelling, for a registration whose source is an
358/// [`SqsQueue`].
359///
360/// The framework's own steps come first - the name builds the source, and `batch(n)` names the
361/// batch size - and these chain after them, in this crate's vocabulary. The bound on the source
362/// type is what keeps them off a builder for another broker.
363///
364/// The trait is in the [prelude](crate::prelude); a file that does not glob it imports the
365/// trait to reach the methods, as with any extension trait.
366///
367/// # Examples
368///
369/// ```
370/// use std::time::Duration;
371///
372/// use ruststream_sqs_sns::prelude::*;
373/// # #[derive(Deserialized)]
374/// # struct Order<'a>(&'a [u8]);
375///
376/// #[subscriber(SqsQueue::new("orders"))]
377/// async fn reconcile(orders: &[Order<'_>]) -> HandlerOutcome {
378///     if orders.iter().any(|order| order.0.is_empty()) {
379///         return HandlerOutcome::retry();
380///     }
381///     HandlerOutcome::ack()
382/// }
383///
384/// #[app]
385/// fn service() -> impl App {
386///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(SqsBroker::new(), |b| {
387///         // The framework's batch size first, then this crate's settings on the same chain.
388///         b.include(
389///             reconcile
390///                 .batch(nonzero!(6))
391///                 .wait(Duration::from_secs(20))
392///                 .create_if_missing(),
393///         );
394///     })
395/// }
396/// ```
397pub trait SqsSubscription: Sized {
398    /// Long-polling wait per receive call. See [`SqsQueue::wait`].
399    #[must_use]
400    fn wait(self, wait: Duration) -> Self;
401
402    /// The visibility timeout requested per receive. See [`SqsQueue::visibility`].
403    #[must_use]
404    fn visibility(self, visibility: Duration) -> Self;
405
406    /// Creates the queue on subscribe when it is missing. See [`SqsQueue::create_if_missing`].
407    #[must_use]
408    fn create_if_missing(self) -> Self;
409}
410
411impl<Def, State, DefCodec> SqsSubscription for SubscriberBuilder<Def, SqsQueue, State, DefCodec>
412where
413    Def: Declared,
414{
415    fn wait(self, wait: Duration) -> Self {
416        self.map_source(|source| source.wait(wait))
417    }
418
419    fn visibility(self, visibility: Duration) -> Self {
420        self.map_source(|source| source.visibility(visibility))
421    }
422
423    fn create_if_missing(self) -> Self {
424        self.map_source(SqsQueue::create_if_missing)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn empty_queue_is_rejected_before_io() {
434        assert!(matches!(
435            SqsQueue::new("").validate(),
436            Err(SqsError::InvalidQueue(_))
437        ));
438    }
439
440    #[test]
441    fn overlong_wait_is_rejected_before_io() {
442        assert!(matches!(
443            SqsQueue::new("q").wait(Duration::from_secs(21)).validate(),
444            Err(SqsError::InvalidQueue(_))
445        ));
446    }
447
448    /// The descriptor as it reaches `subscribe`: what the registration declared is on it.
449    fn declared(declaration: &RetryDeclaration) -> SqsQueue {
450        SubscriptionSource::<ConnectedSqsBroker>::declare_retry(
451            SqsQueue::new("orders"),
452            declaration,
453        )
454    }
455
456    /// A cap of `attempts`, as a mount site declares it.
457    fn cap(attempts: u32) -> NonZeroU32 {
458        NonZeroU32::new(attempts).expect("a cap is never zero")
459    }
460
461    /// The declaration reaches the queue as one policy, so both halves have to be there.
462    #[test]
463    fn a_full_declaration_becomes_the_queues_redrive_policy() {
464        let redrive = declared(
465            &RetryDeclaration::new()
466                .with_max_attempts(cap(4))
467                .with_dead_letter("orders-dead"),
468        )
469        .redrive()
470        .expect("both halves are declared")
471        .expect("a full declaration is a policy");
472        assert_eq!(redrive.max_receive_count.get(), 4);
473        assert_eq!(redrive.dead_letter, "orders-dead");
474    }
475
476    #[test]
477    fn a_registration_that_declares_nothing_writes_no_policy() {
478        let written = declared(&RetryDeclaration::new()).redrive();
479        assert!(
480            matches!(written, Ok(None)),
481            "a silent registration writes nothing"
482        );
483    }
484
485    /// Half a policy is not one, and the subscription says which half is missing rather than
486    /// running with a cap the queue never received.
487    #[test]
488    fn half_a_declaration_is_refused_before_io() {
489        let refused = declared(&RetryDeclaration::new().with_max_attempts(cap(4)))
490            .validate()
491            .expect_err("a cap alone is not a redrive policy");
492        assert!(
493            matches!(
494                refused,
495                SqsError::IncompleteRedrive { missing, .. } if missing == "dead_letter(..)"
496            ),
497            "the refusal names the missing half",
498        );
499
500        let refused = declared(&RetryDeclaration::new().with_dead_letter("orders-dead"))
501            .validate()
502            .expect_err("a destination alone is not a redrive policy");
503        assert!(
504            matches!(
505                refused,
506                SqsError::IncompleteRedrive { missing, .. } if missing == "max_attempts(..)"
507            ),
508            "the refusal names the missing half",
509        );
510    }
511
512    #[test]
513    fn sub_second_visibility_is_rejected_before_io() {
514        assert!(matches!(
515            SqsQueue::new("q")
516                .visibility(Duration::from_millis(999))
517                .validate(),
518            Err(SqsError::InvalidQueue(_))
519        ));
520        assert!(
521            SqsQueue::new("q")
522                .visibility(Duration::from_secs(1))
523                .validate()
524                .is_ok(),
525            "one second is the shortest timeout SQS takes"
526        );
527    }
528
529    /// The value every consumer of the setting reads (the receive, the extender, the in-process
530    /// queue and the document) is the whole number of seconds SQS is asked for.
531    #[test]
532    fn a_fractional_visibility_rounds_up_to_whole_seconds() {
533        let held = |millis| {
534            SqsQueue::new("q")
535                .visibility(Duration::from_millis(millis))
536                .visibility_value()
537        };
538        assert_eq!(held(1_500), Some(Duration::from_secs(2)));
539        assert_eq!(held(2_000), Some(Duration::from_secs(2)));
540        assert_eq!(SqsQueue::new("q").visibility_value(), None);
541    }
542
543    #[test]
544    fn out_of_range_visibility_is_rejected_before_io() {
545        assert!(matches!(
546            SqsQueue::new("q").visibility(Duration::ZERO).validate(),
547            Err(SqsError::InvalidQueue(_))
548        ));
549        assert!(matches!(
550            SqsQueue::new("q")
551                .visibility(Duration::from_hours(13))
552                .validate(),
553            Err(SqsError::InvalidQueue(_))
554        ));
555    }
556}