ruststream_fred/stream.rs
1//! Redis Streams: a stream read through a consumer group, the durable form.
2//!
3//! - [RedisStream][streams]: [`RedisStream`], fresh entries off the tail with
4//! [`RedisStream::new`].
5//! - [reclaim][reclaim]: [`RedisStream::reclaim`], the entries another consumer fetched and never
6//! acked.
7//! - [claiming][claiming]: [`RedisStream::claiming`], pending and fresh entries in one read
8//! (Redis 8.4 and later).
9//! - [StreamStart][stream-start]: [`StreamStart`], where a group starts when it is created.
10//! - [RedisPublish][publish]: [`Publish`], `XADD` to a stream.
11//! - [Transactions][transactions]: [`TransactionalPublish`], the same publisher for replies
12//! committed in one `MULTI` / `EXEC` block; a cluster refuses it at startup.
13//! - [Prelude][prelude]: [`prelude`], everything a Redis Streams service imports.
14//!
15//! [transactions]: https://powersemmi.github.io/ruststream-fred/latest/components/transactions/
16//! [streams]: https://powersemmi.github.io/ruststream-fred/latest/components/streams/
17//! [reclaim]: https://powersemmi.github.io/ruststream-fred/latest/components/streams/reclaim/
18//! [claiming]: https://powersemmi.github.io/ruststream-fred/latest/components/streams/claiming/
19//! [stream-start]: https://powersemmi.github.io/ruststream-fred/latest/components/streams/stream-start/
20//! [publish]: https://powersemmi.github.io/ruststream-fred/latest/components/publishing/
21//! [prelude]: https://powersemmi.github.io/ruststream-fred/latest/components/broker/prelude/
22
23use std::future::{Future, ready};
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::time::Duration;
26
27#[cfg(feature = "asyncapi")]
28use ruststream::asyncapi::Bindings;
29use ruststream::runtime::IntoSource;
30use ruststream::{
31 AddressedCopies, RedeliveryAddress, RedeliveryAddressed, RetryDeclaration, SubscriptionSource,
32};
33
34#[cfg(feature = "asyncapi")]
35use crate::asyncapi;
36use crate::broker::ConnectedRedisBroker;
37use crate::delay::{DelayConfig, DelayedRetry};
38use crate::{error::RedisError, subscriber::RedisSubscriber};
39
40/// This form's publish policy, [`RedisPublish`](crate::RedisPublish), under the mount-site name
41/// every form gives its own.
42pub use crate::publisher::RedisPublish as Publish;
43
44/// This form's transactional publish policy,
45/// [`RedisTransactionalPublish`](crate::RedisTransactionalPublish), under the mount-site name: the
46/// same live publisher as [`Publish`], refused at startup on a cluster.
47pub use crate::publisher::RedisTransactionalPublish as TransactionalPublish;
48
49/// The core prelude plus everything a Redis Streams service writes.
50///
51/// The broker, the [`RedisStream`] descriptor and its options, the group seek types with the
52/// delivery and batch contexts that carry them, this form's [`Publish`] policy, and the
53/// transaction, position and seek capabilities.
54///
55/// # Examples
56///
57/// ```
58/// # mod demo {
59/// use ruststream_fred::stream::prelude::*;
60/// use serde::{Deserialize, Serialize};
61///
62/// #[derive(Deserialize, Serialize, Outgoing)]
63/// #[outgoing(name = "orders.priced")]
64/// struct Order {
65/// id: u64,
66/// }
67///
68/// #[subscriber(RedisStream::new("orders").group("workers"), reply)]
69/// async fn price(orders: &[Order]) -> Result<Vec<Order>, HandlerOutcome> {
70/// Ok(orders.iter().map(|order| Order { id: order.id }).collect())
71/// }
72///
73/// #[ruststream::app]
74/// fn app() -> impl App {
75/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
76/// RedisBroker::standalone("redis://localhost:6379"),
77/// |b| {
78/// // One batch's replies land together, as one MULTI / EXEC block.
79/// b.include(price.batch(nonzero!(32)))
80/// .out_reply(TransactionalPublish)
81/// .transactional();
82/// },
83/// )
84/// }
85/// # }
86/// # fn main() {}
87/// ```
88///
89/// Two vocabularies that do not mix. A handler body imports `ruststream::prelude::*` and bounds an
90/// injected slot with the broker capability trait it needs (`Out<impl Publisher>`, `Out<impl
91/// TransactionalPublisher>`); a routes file globs this prelude and names the policy by its
92/// mount-site word, the same word on every form.
93///
94/// The
95/// [Prelude](https://powersemmi.github.io/ruststream-fred/latest/components/broker/prelude/)
96/// page shows each form's prelude in a service.
97///
98/// A file that also globs another form's prelude sees an ambiguous `Publish`; use
99/// [`crate::prelude`] and write `stream::Publish` there.
100pub mod prelude {
101 pub use ruststream::prelude::*;
102
103 pub use ruststream::{
104 OwnedTransactions, Positioned, Seeker, Transaction, TransactionalPublisher,
105 };
106
107 pub use super::{Publish, RedisStream, StreamStart, TransactionalPublish};
108 // `keys` arrives as the module, not as a glob: its members are short words a service also uses
109 // for its own types, and `Ctx<keys::SeekHandle>` reads as what it is at the use site.
110 pub use crate::context::{
111 PipelineContext, PoolContext, StreamBatchContext, StreamContext, keys,
112 };
113 pub use crate::pipeline::{Bindable, InRound, RedisPipelineSteps};
114 pub use crate::{
115 DelayedRetry, PARTITION_KEY_HEADER, RedisBroker, RedisGroupPosition, RedisGroupSeeker,
116 RedisPublishOptions, RedisPublishSteps, RedisSubscribeExt,
117 };
118
119 #[cfg(any(
120 feature = "tls-rustls",
121 feature = "tls-rustls-ring",
122 feature = "tls-native-tls"
123 ))]
124 pub use crate::{TlsConfig, TlsConnector};
125}
126
127const DEFAULT_BLOCK: Duration = Duration::from_secs(5);
128
129/// Generates an automatic consumer name when the caller does not set one. Distinct names keep
130/// each in-process subscriber's pending list separate within a shared group.
131fn auto_consumer() -> String {
132 static COUNTER: AtomicU64 = AtomicU64::new(0);
133 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
134 format!("ruststream-{n}")
135}
136
137/// Where a freshly created consumer group starts reading from. Only consulted when the group does
138/// not yet exist; an existing group keeps its own cursor.
139#[derive(Debug, Clone, Default)]
140pub enum StreamStart {
141 /// Only entries added after the group is created (`$`). The default.
142 #[default]
143 New,
144 /// Every entry currently in the stream (`0`).
145 Beginning,
146 /// A specific entry ID, exclusive.
147 Id(String),
148}
149
150impl StreamStart {
151 pub(crate) fn as_id(&self) -> &str {
152 match self {
153 Self::New => "$",
154 Self::Beginning => "0",
155 Self::Id(id) => id,
156 }
157 }
158}
159
160#[derive(Debug, Clone)]
161pub(crate) enum ReadMode {
162 /// `XREADGROUP >` - fresh tail.
163 Fresh,
164 /// `XAUTOCLAIM` of entries idle at least this long.
165 Reclaim { min_idle: Duration },
166 /// `XREADGROUP ... CLAIM` - entries idle at least this long, then the fresh tail, in one read.
167 Claiming { min_idle: Duration },
168}
169
170impl ReadMode {
171 /// How a retry redelivers on a subscription reading this way.
172 pub(crate) const fn requeue(&self) -> RequeueMode {
173 match self {
174 Self::Fresh | Self::Reclaim { .. } => RequeueMode::Republish,
175 Self::Claiming { min_idle } => RequeueMode::LeavePending {
176 min_idle: *min_idle,
177 },
178 }
179 }
180}
181
182/// What a retry does with an entry, which is a property of the read mode.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub(crate) enum RequeueMode {
185 /// Append a copy to the stream and ack the original. The retry is immediate, and the copy is
186 /// a new entry whose delivery count starts over.
187 Republish,
188 /// Leave the entry in the pending entries list. The subscription's own next read claims it
189 /// back once it has been idle `min_idle`, with the server's delivery count one higher, which
190 /// is how a claiming subscription retries without duplicating the entry - and what makes a
191 /// delayed retry native there, at that granularity.
192 LeavePending { min_idle: Duration },
193}
194
195/// Describes one Redis Streams subscription against a [`ConnectedRedisBroker`].
196///
197/// # Examples
198///
199/// ```
200/// # mod demo {
201/// use std::time::Duration;
202///
203/// use ruststream_fred::stream::prelude::*;
204/// use serde::Deserialize;
205///
206/// #[derive(Deserialize)]
207/// struct Order {
208/// id: u64,
209/// }
210///
211/// // Fresh tail: the normal worker, reading new entries.
212/// #[subscriber(RedisStream::new("orders").group("workers"))]
213/// async fn handle(order: &Order) -> HandlerOutcome {
214/// tracing::info!(order.id, "order");
215/// HandlerOutcome::ack()
216/// }
217///
218/// // Recovery on the same group: entries a crashed worker left pending for over 30s.
219/// #[subscriber(RedisStream::reclaim("orders", Duration::from_secs(30)).group("workers"))]
220/// async fn recover(order: &Order) -> HandlerOutcome {
221/// tracing::info!(order.id, "recovered");
222/// HandlerOutcome::ack()
223/// }
224///
225/// #[ruststream::app]
226/// fn app() -> impl App {
227/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
228/// RedisBroker::standalone("redis://localhost:6379"),
229/// |b| {
230/// b.include(handle);
231/// b.include(recover);
232/// },
233/// )
234/// }
235/// # }
236/// # fn main() {}
237/// ```
238#[derive(Debug, Clone)]
239#[must_use]
240pub struct RedisStream {
241 key: String,
242 group: Option<String>,
243 consumer: Option<String>,
244 block: Option<Duration>,
245 start: StreamStart,
246 mode: ReadMode,
247 delayed_retry: Option<DelayedRetry>,
248 /// Where the mount site sends a spent delivery, taken from its retry declaration when the
249 /// subscription opens, so the broker writes that name as the stream it is.
250 dead_letter: Option<String>,
251}
252
253impl RedisStream {
254 /// A fresh-tail subscription on `key`: reads new entries via `XREADGROUP >`.
255 ///
256 /// A consumer group is required; set it with [`group`](Self::group).
257 ///
258 /// # Examples
259 ///
260 /// A plain stream has no delivery count of its own, so a retry cap reads the framework's
261 /// retry-count header on the copies the runtime publishes:
262 ///
263 /// ```
264 /// # mod demo {
265 /// use ruststream_fred::stream::prelude::*;
266 /// use serde::Deserialize;
267 ///
268 /// #[derive(Deserialize)]
269 /// struct Order {
270 /// id: u64,
271 /// }
272 ///
273 /// // The handler counts nothing itself.
274 /// #[subscriber(RedisStream::new("orders").group("workers"))]
275 /// async fn handle(order: &Order) -> HandlerOutcome {
276 /// if order.id == 0 {
277 /// return HandlerOutcome::retry();
278 /// }
279 /// HandlerOutcome::ack()
280 /// }
281 ///
282 /// #[ruststream::app]
283 /// fn app() -> impl App {
284 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
285 /// RedisBroker::standalone("redis://localhost:6379"),
286 /// |b| {
287 /// // Five deliveries per message, counting the first. The fifth failure sends it to
288 /// // "orders.dlq" instead of back to "orders".
289 /// b.include(handle)
290 /// .max_attempts(nonzero!(5u32))
291 /// .dead_letter("orders.dlq");
292 /// },
293 /// )
294 /// }
295 /// # }
296 /// # fn main() {}
297 /// ```
298 pub fn new(key: impl Into<String>) -> Self {
299 Self {
300 key: key.into(),
301 group: None,
302 consumer: None,
303 block: None,
304 start: StreamStart::New,
305 mode: ReadMode::Fresh,
306 delayed_retry: None,
307 dead_letter: None,
308 }
309 }
310
311 /// A recovery subscription on `key`: reclaims pending entries idle at least `min_idle` via
312 /// `XAUTOCLAIM`. Run it alongside a [`new`](Self::new) subscriber on the same group to pick up
313 /// messages a consumer fetched but died before acking.
314 ///
315 /// `min_idle` has no default and must exceed the longest legitimate handler runtime: set it too
316 /// low and a healthy consumer's in-flight message gets reclaimed and processed twice.
317 ///
318 /// # Examples
319 ///
320 /// ```
321 /// # mod demo {
322 /// use std::time::Duration;
323 ///
324 /// use ruststream_fred::stream::prelude::*;
325 /// use serde::Deserialize;
326 ///
327 /// #[derive(Deserialize)]
328 /// struct Order {
329 /// id: u64,
330 /// }
331 ///
332 /// #[subscriber(RedisStream::new("orders").group("workers"))]
333 /// async fn handle(order: &Order) -> HandlerOutcome {
334 /// tracing::info!(order.id, "processing");
335 /// HandlerOutcome::ack()
336 /// }
337 ///
338 /// // Recovery on a server older than 8.4: a reclaim subscription beside the fresh-tail one, on the
339 /// // same group, takes the orders a dead worker fetched and never finished.
340 /// #[subscriber(RedisStream::reclaim("orders", Duration::from_secs(30)).group("workers"))]
341 /// async fn recover(order: &Order) -> HandlerOutcome {
342 /// tracing::info!(order.id, "recovered");
343 /// HandlerOutcome::ack()
344 /// }
345 ///
346 /// #[ruststream::app]
347 /// fn app() -> impl App {
348 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
349 /// RedisBroker::standalone("redis://localhost:6379"),
350 /// |b| {
351 /// b.include(handle);
352 /// b.include(recover);
353 /// },
354 /// )
355 /// }
356 /// # }
357 /// # fn main() {}
358 /// ```
359 pub fn reclaim(key: impl Into<String>, min_idle: Duration) -> Self {
360 Self {
361 key: key.into(),
362 group: None,
363 consumer: None,
364 block: None,
365 start: StreamStart::New,
366 mode: ReadMode::Reclaim { min_idle },
367 delayed_retry: None,
368 dead_letter: None,
369 }
370 }
371
372 /// A subscription on `key` that claims and reads in one call: `XREADGROUP ... CLAIM` first
373 /// takes over the group's entries idle at least `min_idle`, longest idle first, then fills the
374 /// rest of the read with fresh ones. One subscription, one consumer, one handler.
375 ///
376 /// Every delivery carries the server's own
377 /// [`IDLE_MS_HEADER`](crate::IDLE_MS_HEADER) and
378 /// [`DELIVERY_COUNT_HEADER`](crate::DELIVERY_COUNT_HEADER), both as of the delivery before
379 /// this one: a fresh entry reports zero for both, and the first claim back reports one earlier
380 /// delivery. The count is therefore the number of attempts that did not finish, which is what
381 /// a handler reads to give up on a message itself.
382 ///
383 /// This is Redis's own redelivery, so this mode is the one that settles a retry natively.
384 /// `retry()` leaves the entry in the pending entries list rather than appending a copy, so the
385 /// next read brings it back under its own id with the count one higher, no sooner than
386 /// `min_idle` from now. `retry_after(delay)` takes the same path and the entry comes back
387 /// after `min_idle`, so a delay shorter than that rounds up to it; name a
388 /// [`delayed_retry`](Self::delayed_retry) queue to have the delay honoured exactly instead.
389 /// The delivery reports its count to the runtime, which is what a `max_attempts` declaration
390 /// at the mount site caps.
391 ///
392 /// `min_idle` has no default and must exceed the longest legitimate handler runtime: below it,
393 /// a message a healthy consumer is still working on is taken away and processed twice.
394 ///
395 /// Redis 8.4.0 added the option. A registration of this mode against an older server refuses
396 /// to start, naming both versions; on such a server use [`new`](Self::new) with a
397 /// [`reclaim`](Self::reclaim) subscription beside it.
398 ///
399 /// # Examples
400 ///
401 /// ```
402 /// # mod demo {
403 /// use std::time::Duration;
404 ///
405 /// use ruststream_fred::stream::prelude::*;
406 /// use serde::Deserialize;
407 ///
408 /// #[derive(Deserialize)]
409 /// struct Order {
410 /// id: u64,
411 /// in_stock: bool,
412 /// }
413 ///
414 /// // New orders and the ones a dead worker took and never finished, in one subscription. A
415 /// // retry stays pending and comes back after 30s under its own id.
416 /// #[subscriber(RedisStream::claiming("orders", Duration::from_secs(30)).group("workers"))]
417 /// async fn handle(order: &Order) -> HandlerOutcome {
418 /// if !order.in_stock {
419 /// return HandlerOutcome::retry();
420 /// }
421 /// tracing::info!(order.id, "order");
422 /// HandlerOutcome::ack()
423 /// }
424 ///
425 /// #[ruststream::app]
426 /// fn app() -> impl App {
427 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
428 /// RedisBroker::standalone("redis://localhost:6379"),
429 /// |b| {
430 /// // The server's delivery count feeds the cap.
431 /// b.include(handle)
432 /// .max_attempts(nonzero!(5u32))
433 /// .dead_letter("orders.dlq");
434 /// },
435 /// )
436 /// }
437 /// # }
438 /// # fn main() {}
439 /// ```
440 pub fn claiming(key: impl Into<String>, min_idle: Duration) -> Self {
441 Self {
442 key: key.into(),
443 group: None,
444 consumer: None,
445 block: None,
446 start: StreamStart::New,
447 mode: ReadMode::Claiming { min_idle },
448 delayed_retry: None,
449 dead_letter: None,
450 }
451 }
452
453 /// Sets the consumer group. Required for every subscription.
454 pub fn group(mut self, group: impl Into<String>) -> Self {
455 self.group = Some(group.into());
456 self
457 }
458
459 /// Sets this consumer's name within the group. Defaults to an auto-generated unique name.
460 pub fn consumer(mut self, consumer: impl Into<String>) -> Self {
461 self.consumer = Some(consumer.into());
462 self
463 }
464
465 /// How long one read blocks waiting for entries. Defaults to 5 seconds. In the fresh-tail and
466 /// claiming modes this is the `XREADGROUP` server-side block, which wakes on a new entry and,
467 /// under `CLAIM`, on an entry ageing past the threshold; in reclaim mode `XAUTOCLAIM` does not
468 /// block, so this is the poll interval slept between scans that find nothing to reclaim.
469 ///
470 /// Also reachable at the mount site, after the batch size, through
471 /// [`RedisSubscribeExt`](crate::RedisSubscribeExt).
472 pub const fn block(mut self, block: Duration) -> Self {
473 self.block = Some(block);
474 self
475 }
476
477 /// Where a newly created group starts reading. Ignored if the group already exists. Only
478 /// meaningful for the modes that read the tail, [`new`](Self::new) and
479 /// [`claiming`](Self::claiming).
480 pub fn start_id(mut self, start: StreamStart) -> Self {
481 self.start = start;
482 self
483 }
484
485 /// Opts this subscription into durable, crash-safe delayed retry backed by a ZSET delay queue.
486 ///
487 /// Off by default: without it, `retry_after(delay)` / `nack_after(delay)` degrade to the
488 /// runtime's broker-agnostic deferred re-publish, which the mount site binds with `out_retry`
489 /// and which is at-most-once over the delay window. With it, a delayed delivery is `ZADD`ed to
490 /// the named ZSET and replayed from there once due, so the retry survives a process crash. See
491 /// [`DelayedRetry`] for the key and TTL requirements.
492 ///
493 /// The sweeper that replays due entries runs inside this subscription's read loop, so its
494 /// granularity is the read [`block`](Self::block) interval.
495 pub fn delayed_retry(mut self, retry: DelayedRetry) -> Self {
496 self.delayed_retry = Some(retry);
497 self
498 }
499
500 /// The stream key this subscription reads.
501 #[must_use]
502 pub fn key(&self) -> &str {
503 &self.key
504 }
505
506 pub(crate) fn group_or_err(&self) -> Result<&str, RedisError> {
507 self.group.as_deref().ok_or_else(|| {
508 RedisError::InvalidOptions(format!(
509 "stream subscription on `{}` requires a consumer group: call .group(name)",
510 self.key
511 ))
512 })
513 }
514
515 pub(crate) fn consumer_or_auto(&self) -> String {
516 self.consumer.clone().unwrap_or_else(auto_consumer)
517 }
518
519 pub(crate) fn block_or_default(&self) -> Duration {
520 self.block.unwrap_or(DEFAULT_BLOCK)
521 }
522
523 pub(crate) const fn start(&self) -> &StreamStart {
524 &self.start
525 }
526
527 pub(crate) fn mode(&self) -> ReadMode {
528 self.mode.clone()
529 }
530
531 /// The dead-letter destination the mount site declared, once the registration has handed its
532 /// declaration over.
533 pub(crate) fn dead_letter(&self) -> Option<&str> {
534 self.dead_letter.as_deref()
535 }
536
537 /// Takes the dead-letter destination out of the mount site's declaration.
538 fn with_declaration(mut self, declaration: &RetryDeclaration) -> Self {
539 self.dead_letter = declaration.dead_letter().map(str::to_owned);
540 self
541 }
542
543 pub(crate) fn delay_config(&self) -> Option<DelayConfig> {
544 self.delayed_retry.as_ref().map(DelayConfig::from_retry)
545 }
546
547 /// Where a publisher reaches this subscription again, shared by both broker forms.
548 fn redelivery_key(&self) -> RedeliveryAddress {
549 RedeliveryAddress::new(self.key.clone())
550 }
551
552 /// What this subscription adds to its channel in the generated `AsyncAPI` document, shared by
553 /// both broker forms: the group and consumer it reads through, its read mode and, on the two
554 /// modes that claim, the idle threshold they claim at.
555 #[cfg(feature = "asyncapi")]
556 fn describe(&self) -> Bindings {
557 let (mode, min_idle) = match self.mode {
558 ReadMode::Fresh => (asyncapi::ReadMode::Fresh, None),
559 ReadMode::Reclaim { min_idle } => (asyncapi::ReadMode::Reclaim, Some(min_idle)),
560 ReadMode::Claiming { min_idle } => (asyncapi::ReadMode::Claiming, Some(min_idle)),
561 };
562 asyncapi::channel(&asyncapi::StreamSubscription::new(
563 self.group.as_deref(),
564 self.consumer.as_deref(),
565 mode,
566 min_idle.map(|idle| u64::try_from(idle.as_millis()).unwrap_or(u64::MAX)),
567 ))
568 }
569}
570
571// The manual path binds a `Handle` body to its source with `subscriber(source, body)`, which
572// takes the descriptor as it is, the way the `#[subscriber(..)]` attribute does.
573impl IntoSource for RedisStream {
574 type Source = Self;
575
576 fn into_source(self) -> Self {
577 self
578 }
579}
580
581impl SubscriptionSource<ConnectedRedisBroker> for RedisStream {
582 type Subscriber = RedisSubscriber;
583 /// A retry copy is an `XADD` this process makes, and the stream key is where it goes.
584 type Copies = AddressedCopies;
585
586 fn name(&self) -> &str {
587 self.key()
588 }
589
590 fn declare_retry(self, declaration: &RetryDeclaration) -> Self {
591 self.with_declaration(declaration)
592 }
593
594 async fn subscribe(
595 self,
596 connected: &ConnectedRedisBroker,
597 ) -> Result<Self::Subscriber, RedisError> {
598 connected.subscribe(self).await
599 }
600
601 #[cfg(feature = "asyncapi")]
602 fn channel_bindings(&self) -> Bindings {
603 self.describe()
604 }
605}
606
607/// The stream key, in every read mode: an `XADD` there is read by the consumer group this
608/// subscription belongs to.
609///
610/// Under [`reclaim`](RedisStream::reclaim) the copy is read by the group's tail reader rather
611/// than by this subscription, which only ever takes entries already pending on another consumer.
612/// That is the topology the mode is written for - a `reclaim` subscriber runs beside a
613/// [`new`](RedisStream::new) one on the same group - so the copy reaches the handler either way.
614impl RedeliveryAddressed<ConnectedRedisBroker> for RedisStream {
615 fn redelivery_address(
616 &self,
617 _connected: &ConnectedRedisBroker,
618 ) -> impl Future<Output = Result<RedeliveryAddress, RedisError>> {
619 ready(Ok(self.redelivery_key()))
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
628 fn group_is_required() {
629 let err = RedisStream::new("orders").group_or_err().unwrap_err();
630 assert!(matches!(err, RedisError::InvalidOptions(msg) if msg.contains("consumer group")));
631 }
632
633 #[test]
634 fn group_set_resolves() {
635 let s = RedisStream::new("orders").group("workers");
636 assert_eq!(s.group_or_err().expect("group set"), "workers");
637 }
638
639 #[test]
640 fn start_maps_to_redis_ids() {
641 assert_eq!(StreamStart::New.as_id(), "$");
642 assert_eq!(StreamStart::Beginning.as_id(), "0");
643 assert_eq!(StreamStart::Id("5-0".into()).as_id(), "5-0");
644 }
645
646 #[test]
647 fn reclaim_carries_min_idle() {
648 let s = RedisStream::reclaim("orders", Duration::from_secs(30)).group("g");
649 assert!(matches!(s.mode(), ReadMode::Reclaim { min_idle } if min_idle.as_secs() == 30));
650 }
651
652 #[test]
653 fn claiming_carries_min_idle() {
654 let s = RedisStream::claiming("orders", Duration::from_secs(30)).group("g");
655 assert!(matches!(s.mode(), ReadMode::Claiming { min_idle } if min_idle.as_secs() == 30));
656 }
657
658 /// Every read mode answers with the stream key: an `XADD` there is read by the group, which
659 /// is what a retry copy has to reach.
660 #[test]
661 fn every_read_mode_addresses_its_copies_to_the_stream_key() {
662 let idle = Duration::from_secs(30);
663 for source in [
664 RedisStream::new("orders"),
665 RedisStream::claiming("orders", idle),
666 RedisStream::reclaim("orders", idle),
667 ] {
668 assert_eq!(source.redelivery_key().as_str(), "orders");
669 }
670 }
671
672 /// The retry a read mode performs is the read mode's own: the claiming mode leaves the entry
673 /// pending so its next read claims it back, and the other two append a copy.
674 #[test]
675 fn the_claiming_mode_retries_by_leaving_the_entry_pending() {
676 let idle = Duration::from_secs(30);
677 assert_eq!(
678 RedisStream::claiming("orders", idle).mode().requeue(),
679 RequeueMode::LeavePending { min_idle: idle }
680 );
681 assert_eq!(
682 RedisStream::new("orders").mode().requeue(),
683 RequeueMode::Republish
684 );
685 assert_eq!(
686 RedisStream::reclaim("orders", idle).mode().requeue(),
687 RequeueMode::Republish
688 );
689 }
690}