Skip to main content

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}