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}