Skip to main content

ruststream_fred/
stream.rs

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