Skip to main content

ruststream_fred/
subscriber.rs

1//! Redis Streams subscriber driving `XREADGROUP` (the fresh tail, or claim-and-read on Redis 8.4
2//! and later) or `XAUTOCLAIM` (reclaim).
3
4use std::collections::{HashMap, VecDeque};
5use std::fmt::{Debug, Formatter};
6use std::num::NonZeroUsize;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::Duration;
10
11use fred::clients::Client;
12use fred::error::Error as FredError;
13use fred::interfaces::{ClientLike, StreamsInterface};
14use fred::types::streams::XReadValue;
15use fred::types::{CustomCommand, Value};
16use futures::Stream;
17use futures::stream::unfold;
18use ruststream::{BatchSubscriber, Seekable, Subscriber};
19use tracing::warn;
20
21use crate::broker::ensure_group;
22use crate::claim::{self, ClaimedEntry};
23use crate::connection::{Connection, ReadConnection};
24use crate::convert::{HEADER_PREFIX, parts_from_fields};
25use crate::delay::DelayConfig;
26use crate::loopback::Tap;
27use crate::message::{DELIVERY_COUNT_HEADER, IDLE_MS_HEADER, RedisMessage};
28use crate::pipeline::{RoundMessage, StreamForm, Window};
29use crate::seek::{EntryId, RedisGroupSeeker};
30use crate::{error::RedisError, stream::ReadMode};
31
32/// One decoded stream entry: its id, its field map, and the server's delivery count where the read
33/// mode reports one.
34///
35/// The count reaches the delivery as a value rather than only as a header, because the runtime
36/// reads it to apply the registration's `max_attempts` declaration.
37struct Entry {
38    id: String,
39    fields: HashMap<String, Vec<u8>>,
40    /// Deliveries of this entry counting this one, on the two modes that claim. `None` on the
41    /// fresh tail, which claims nothing.
42    delivered: Option<u64>,
43}
44
45/// `XREADGROUP` reply shape parsed as nested arrays rather than maps: the RESP2 reply is an array of
46/// `[key, [[id, [field, value, ...]], ...]]`, which does not convert to fred's map-based
47/// `XReadResponse` (the outer array is not a flat key/value list). Pairing into tuples does work, so
48/// we collect the entry fields into a map ourselves.
49type RawStreams = Vec<(String, Vec<(String, Vec<(String, Vec<u8>)>)>)>;
50
51/// Cursor a fresh reclaim scan starts from (the whole pending list).
52const RECLAIM_START: &str = "0-0";
53
54/// The command the claiming mode sends. It goes out as a custom command because `CLAIM` changes
55/// the reply shape, which the typed `xreadgroup` of `fred` cannot parse.
56const XREADGROUP: &str = "XREADGROUP";
57
58/// `COUNT` for a read on the single-message path, where the framework names no batch size: a read
59/// that fetched one entry per round trip would spend a round trip per message, so the loop
60/// prefetches and drains the buffer between reads. Batches take their own `COUNT` from the size the
61/// mount site asked for instead.
62pub(crate) const PREFETCH: u64 = 64;
63
64/// Where a group the server lost starts again: the first entry of the stream.
65const RECREATED_GROUP_START: &str = "0";
66
67/// Whether the server refused a read because the group (or its stream) no longer exists.
68fn lost_group(err: &RedisError) -> bool {
69    matches!(err, RedisError::Stream(source)
70        if source
71            .downcast_ref::<FredError>()
72            .is_some_and(|err| err.details().starts_with("NOGROUP")))
73}
74
75fn duration_to_millis(d: Duration) -> u64 {
76    u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
77}
78
79/// A Redis Streams subscription bound to a consumer group.
80///
81/// Constructed by [`crate::ConnectedRedisBroker::subscribe`] from a [`crate::RedisStream`]
82/// descriptor. The read mode (fresh tail, reclaim, or claim-and-read) is fixed at construction.
83pub struct RedisSubscriber {
84    connection: Arc<Connection>,
85    /// The connection every read goes out on, apart from the pool: a blocking read holds its
86    /// connection until it returns.
87    reader: ReadConnection,
88    key: String,
89    group: String,
90    consumer: String,
91    block: Duration,
92    mode: ReadMode,
93    /// Set when the subscription opted into a durable ZSET delay queue; drives native `nack_after`
94    /// on each delivery and the due-entry sweep on each fetch.
95    delay: Option<DelayConfig>,
96    /// Reclaim cursor; advances across `XAUTOCLAIM` calls, unused in fresh mode.
97    cursor: String,
98    /// Entries fetched but not yet yielded.
99    buffer: VecDeque<Entry>,
100    /// Bumped by every [`RedisGroupSeeker`] minted off this subscription. Shared, because a seek
101    /// can land while this subscriber is parked in a blocking read.
102    generation: Arc<AtomicU64>,
103    /// The generation the buffered entries were selected under. A mismatch means a seek moved the
104    /// cursor after they were chosen, so they belong to the position the group left.
105    buffer_generation: u64,
106    /// Minted once, when the subscription opens, and handed to every delivery so its context can
107    /// carry the handle without building one per message.
108    seeker: Arc<RedisGroupSeeker>,
109    /// The subscription's registration with the in-process server; empty on a real connection.
110    tap: Tap,
111}
112
113impl Debug for RedisSubscriber {
114    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("RedisSubscriber")
116            .field("key", &self.key)
117            .field("group", &self.group)
118            .field("consumer", &self.consumer)
119            .field("mode", &self.mode)
120            .finish_non_exhaustive()
121    }
122}
123
124impl RedisSubscriber {
125    #[allow(
126        clippy::too_many_arguments,
127        reason = "internal constructor mirroring the descriptor"
128    )]
129    pub(crate) fn new(
130        connection: Arc<Connection>,
131        reader: ReadConnection,
132        key: String,
133        group: String,
134        consumer: String,
135        block: Duration,
136        mode: ReadMode,
137        delay: Option<DelayConfig>,
138        tap: Tap,
139    ) -> Self {
140        let generation = Arc::new(AtomicU64::new(0));
141        let seeker = Arc::new(RedisGroupSeeker::new(
142            Arc::clone(&connection),
143            key.as_str(),
144            group.as_str(),
145            Arc::clone(&generation),
146        ));
147        Self {
148            connection,
149            reader,
150            key,
151            group,
152            consumer,
153            block,
154            mode,
155            delay,
156            cursor: RECLAIM_START.to_owned(),
157            buffer: VecDeque::new(),
158            generation,
159            buffer_generation: 0,
160            seeker,
161            tap,
162        }
163    }
164
165    /// Builds the delivery for one fetched entry.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`RedisError::Stream`] when the server sent an id that is not a well-formed
170    /// `<milliseconds>-<sequence>` pair, which would leave the delivery unable to report its
171    /// position.
172    fn message(&self, entry: Entry) -> Result<RedisMessage, RedisError> {
173        let id = entry.id;
174        let parsed: EntryId = id.parse()?;
175        let (payload, headers) = parts_from_fields(entry.fields);
176        Ok(RedisMessage::new(
177            self.connection.pool().clone(),
178            self.key.clone(),
179            self.group.clone(),
180            id,
181            parsed,
182            payload,
183            headers,
184            self.delay.clone(),
185            entry.delivered,
186            Arc::clone(&self.seeker),
187            self.mode.requeue(),
188            self.tap.delivered(),
189        ))
190    }
191
192    /// Drops entries selected before a seek moved the group cursor: they belong to the position
193    /// the group left, so delivering them would contradict the reposition. Returns how many it
194    /// dropped.
195    fn discard_stale(&mut self) -> usize {
196        let current = self.generation.load(Ordering::Acquire);
197        if self.buffer_generation == current {
198            return 0;
199        }
200        let dropped = self.buffer.len();
201        self.buffer.clear();
202        self.buffer_generation = current;
203        #[cfg(feature = "testing")]
204        self.tap.discard(dropped);
205        dropped
206    }
207
208    /// The stream key this subscription reads.
209    pub(crate) fn key(&self) -> &str {
210        &self.key
211    }
212
213    /// The settle side of this subscription inside a window.
214    pub(crate) fn round_form(&self) -> StreamForm {
215        StreamForm::new(
216            self.key.as_str(),
217            self.group.as_str(),
218            self.delay.clone(),
219            self.mode.requeue(),
220        )
221    }
222
223    /// The window's client: a connection of the pool, where no read blocks.
224    pub(crate) fn round_client(&self) -> Client {
225        self.connection.pool().next().clone()
226    }
227
228    /// Yields one delivery per entry, each with a slot in `window`, and reports a failed flush of
229    /// the window once.
230    pub(crate) fn round_stream<'a>(
231        &'a mut self,
232        window: &'a Arc<Window<StreamForm>>,
233    ) -> impl Stream<Item = Result<RoundMessage<StreamForm>, RedisError>> + Send + 'a {
234        unfold(self, move |s| async move {
235            loop {
236                if let Some(failure) = window.take_failure() {
237                    return Some((Err(failure), s));
238                }
239                window.discarded(s.discard_stale());
240                if let Some(entry) = s.buffer.pop_front() {
241                    let delivery = match s.message(entry) {
242                        Ok(delivery) => delivery,
243                        Err(err) => {
244                            window.discarded(1);
245                            return Some((Err(err), s));
246                        }
247                    };
248                    let round = window.open();
249                    return Some((
250                        Ok(RoundMessage::new(delivery, Arc::clone(window), round)),
251                        s,
252                    ));
253                }
254                if let Err(err) = s.fetch(PREFETCH).await {
255                    return Some((Err(err), s));
256                }
257                window.fetched(s.buffer.len());
258            }
259        })
260    }
261
262    /// Fetches up to `count` entries into the buffer. A read that timed out with nothing pending
263    /// leaves the buffer empty (the caller loops and reads again).
264    async fn fetch(&mut self, count: u64) -> Result<(), RedisError> {
265        // In process the read below cannot block, so the wait it would hold open is taken here,
266        // before the sweep, which then finds what came due while it waited.
267        #[cfg(feature = "testing")]
268        self.tap.readable().await;
269        // The read connection is the subscription's own, so the broker's shutdown does not close
270        // it: a read after the shutdown refuses here instead of delivering past it.
271        self.connection.ensure_open()?;
272        // Replay any due delayed-retry entries before reading, so they re-enter the stream and get
273        // delivered through the normal read path. Granularity is the read block interval.
274        if let Some(cfg) = &self.delay {
275            cfg.sweep_due(self.connection.pool(), &self.key).await?;
276        }
277        // Captured before the read, not after: a blocking read selects its entries against the
278        // cursor as it stood when the read started, so a seek that lands mid-read invalidates
279        // whatever comes back.
280        let selected_at = self.generation.load(Ordering::Acquire);
281        let read = match self.mode.clone() {
282            ReadMode::Fresh => self.fetch_fresh(count).await,
283            ReadMode::Reclaim { min_idle } => self.fetch_reclaim(min_idle, count).await,
284            ReadMode::Claiming { min_idle } => self.fetch_claiming(min_idle, count).await,
285        };
286        let entries = match read {
287            Ok(entries) => entries,
288            Err(err) if lost_group(&err) => return self.recreate_group().await,
289            Err(err) => return Err(err),
290        };
291        if selected_at != self.generation.load(Ordering::Acquire) {
292            // A seek overtook this read; drop its entries and let the caller read again.
293            #[cfg(feature = "testing")]
294            self.tap.discard(entries.len());
295            return Ok(());
296        }
297        self.buffer_generation = selected_at;
298        self.buffer.extend(entries);
299        Ok(())
300    }
301
302    /// Creates the group again after the server lost it, and reads it from the start of the
303    /// stream.
304    ///
305    /// A server that keeps no data comes back from a restart without the stream and its groups,
306    /// and every read after that is refused. Everything the stream holds by now was added after
307    /// the loss, so reading from the start delivers it all and nothing published in between is
308    /// skipped. The reclaim cursor starts over with the group.
309    #[cold]
310    async fn recreate_group(&mut self) -> Result<(), RedisError> {
311        warn!(
312            subscription = self.key.as_str(),
313            group = self.group.as_str(),
314            "the Redis server lost this stream's consumer group, as after a restart that kept no \
315             data; creating it again and reading the stream from its start"
316        );
317        ensure_group(
318            self.connection.pool(),
319            &self.key,
320            &self.group,
321            RECREATED_GROUP_START,
322        )
323        .await?;
324        RECLAIM_START.clone_into(&mut self.cursor);
325        Ok(())
326    }
327
328    async fn fetch_fresh(&self, count: u64) -> Result<Vec<Entry>, RedisError> {
329        let resp: RawStreams = self
330            .reader
331            .client()
332            .xreadgroup(
333                self.group.as_str(),
334                self.consumer.as_str(),
335                Some(count),
336                Some(duration_to_millis(self.block)),
337                false,
338                self.key.as_str(),
339                ">",
340            )
341            .await
342            .map_err(RedisError::stream)?;
343        let entries = resp
344            .into_iter()
345            .find(|(key, _)| key == &self.key)
346            .map(|(_, entries)| entries)
347            .unwrap_or_default();
348        Ok(entries
349            .into_iter()
350            .map(|(id, fields)| Entry {
351                id,
352                fields: fields.into_iter().collect(),
353                delivered: None,
354            })
355            .collect())
356    }
357
358    /// One `XREADGROUP GROUP g c COUNT n BLOCK ms CLAIM min-idle STREAMS key >`: the group's
359    /// entries idle at least `min_idle` first, longest idle first, then fresh ones up to the
360    /// remaining `COUNT`. The read blocks on both a new entry and an entry ageing past the
361    /// threshold.
362    ///
363    /// It goes out as a custom command because `CLAIM` makes every entry `[id, fields, idle-ms,
364    /// delivery-count]`, which the typed `xreadgroup` of `fred` parses as a two-element entry and
365    /// rejects. The two extra fields become the headers the reclaim path already writes, so a
366    /// handler reads them the same way on either mode.
367    async fn fetch_claiming(
368        &self,
369        min_idle: Duration,
370        count: u64,
371    ) -> Result<Vec<Entry>, RedisError> {
372        // The key is hashed into the command's cluster slot: it is not the first argument, so the
373        // default "hash the first key" policy would route the read by the group name.
374        let command = CustomCommand::new_static(XREADGROUP, self.key.as_str(), true);
375        // Every argument goes as a string, which is what a Redis command is on the wire.
376        let args: Vec<Value> = [
377            "GROUP",
378            self.group.as_str(),
379            self.consumer.as_str(),
380            "COUNT",
381            &count.to_string(),
382            "BLOCK",
383            &duration_to_millis(self.block).to_string(),
384            "CLAIM",
385            &duration_to_millis(min_idle).to_string(),
386            "STREAMS",
387            self.key.as_str(),
388            ">",
389        ]
390        .into_iter()
391        .map(Value::from)
392        .collect();
393
394        let frame = self
395            .reader
396            .client()
397            .custom_raw(command, args)
398            .await
399            .map_err(RedisError::stream)?;
400        let claimed = claim::decode_reply(&frame, &self.key)?;
401        Ok(Self::annotate_claimed(claimed))
402    }
403
404    /// Hands each claimed entry its two counters: as headers a handler can read, and as the
405    /// delivery count the runtime caps on.
406    ///
407    /// Unlike the reclaim path this needs no round trip of its own: `CLAIM` reports both counters
408    /// with the entry. It reports them as of the delivery before this one, so the count this
409    /// delivery makes is added on, which is where the reclaim path's `XPENDING` value already
410    /// stands - the two modes then cap at the same attempt.
411    fn annotate_claimed(claimed: Vec<ClaimedEntry>) -> Vec<Entry> {
412        claimed
413            .into_iter()
414            .map(|entry| {
415                let mut fields = entry.fields;
416                insert_meta_header(&mut fields, DELIVERY_COUNT_HEADER, entry.delivery_count);
417                insert_meta_header(&mut fields, IDLE_MS_HEADER, entry.idle_ms);
418                Entry {
419                    id: entry.id,
420                    fields,
421                    delivered: Some(entry.delivery_count + 1),
422                }
423            })
424            .collect()
425    }
426
427    async fn fetch_reclaim(
428        &mut self,
429        min_idle: Duration,
430        count: u64,
431    ) -> Result<Vec<Entry>, RedisError> {
432        let (cursor, entries): (String, Vec<XReadValue<String, String, Vec<u8>>>) = self
433            .reader
434            .client()
435            .xautoclaim_values(
436                self.key.as_str(),
437                self.group.as_str(),
438                self.consumer.as_str(),
439                duration_to_millis(min_idle),
440                self.cursor.as_str(),
441                Some(count),
442                false,
443            )
444            .await
445            .map_err(RedisError::stream)?;
446        self.cursor = cursor;
447        // Nothing left to reclaim this pass: avoid a hot loop until more entries go stale.
448        if entries.is_empty() {
449            // In process the next fetch waits until an entry goes stale instead.
450            #[cfg(feature = "testing")]
451            if self.tap.in_process() {
452                return Ok(Vec::new());
453            }
454            tokio::time::sleep(self.block).await;
455            return Ok(Vec::new());
456        }
457        self.enrich_reclaimed(entries, count).await
458    }
459
460    /// Annotates reclaimed entries with their native delivery count and idle time.
461    ///
462    /// `XAUTOCLAIM` does not report either, so this costs one `XPENDING` per read that found
463    /// something - not per entry. It is what the mode answers `redelivery_count` with, which is
464    /// how a `max_attempts` declaration at the mount site reaches a reclaimed delivery.
465    async fn enrich_reclaimed(
466        &self,
467        entries: Vec<XReadValue<String, String, Vec<u8>>>,
468        limit: u64,
469    ) -> Result<Vec<Entry>, RedisError> {
470        let meta = self.pending_meta(limit).await?;
471        let mut out = Vec::with_capacity(entries.len());
472        for (id, mut fields) in entries {
473            let (idle, count) = meta.get(&id).copied().unwrap_or((0, 0));
474            insert_meta_header(&mut fields, DELIVERY_COUNT_HEADER, count);
475            insert_meta_header(&mut fields, IDLE_MS_HEADER, idle);
476            out.push(Entry {
477                id,
478                fields,
479                delivered: Some(count),
480            });
481        }
482        Ok(out)
483    }
484
485    /// Maps each of this consumer's pending entry IDs to its `(idle_ms, delivery_count)` via
486    /// extended `XPENDING`, which - unlike `XAUTOCLAIM` - reports the native delivery count.
487    async fn pending_meta(&self, limit: u64) -> Result<HashMap<String, (u64, u64)>, RedisError> {
488        let rows: Vec<(String, String, u64, u64)> = self
489            .reader
490            .client()
491            .xpending(
492                self.key.as_str(),
493                self.group.as_str(),
494                (0_u64, "-", "+", limit, self.consumer.as_str()),
495            )
496            .await
497            .map_err(RedisError::stream)?;
498        Ok(rows
499            .into_iter()
500            .map(|(id, _consumer, idle, count)| (id, (idle, count)))
501            .collect())
502    }
503}
504
505impl RedisSubscriber {
506    /// Yields one batch per read, native all the way down as the batches without a window are:
507    /// `size` is the read's `COUNT`, and the batch shares one slot of `window`, so what the batch
508    /// handler queued commits with the settles of every entry.
509    pub(crate) fn round_batches<'a>(
510        &'a mut self,
511        window: &'a Arc<Window<StreamForm>>,
512        size: NonZeroUsize,
513    ) -> impl Stream<Item = Result<Vec<RoundMessage<StreamForm>>, RedisError>> + Send + 'a {
514        let count = u64::try_from(size.get()).unwrap_or(u64::MAX);
515        window.fill_at(size.get());
516        unfold(self, move |s| async move {
517            loop {
518                if let Some(failure) = window.take_failure() {
519                    return Some((Err(failure), s));
520                }
521                window.discarded(s.discard_stale());
522                if !s.buffer.is_empty() {
523                    let tail = s.buffer.split_off(size.get().min(s.buffer.len()));
524                    let entries = std::mem::replace(&mut s.buffer, tail);
525                    let members = u32::try_from(entries.len()).unwrap_or(u32::MAX);
526                    let round = window.open_batch(members);
527                    let mut batch = Vec::with_capacity(entries.len());
528                    for entry in entries {
529                        match s.message(entry) {
530                            Ok(delivery) => batch.push(RoundMessage::new(
531                                delivery,
532                                Arc::clone(window),
533                                round.clone(),
534                            )),
535                            Err(err) => return Some((Err(err), s)),
536                        }
537                    }
538                    return Some((Ok(batch), s));
539                }
540                if let Err(err) = s.fetch(count).await {
541                    return Some((Err(err), s));
542                }
543                window.fetched(s.buffer.len());
544            }
545        })
546    }
547}
548
549/// Injects a `u64`-valued well-known header into an entry's raw field map (under the `h:` prefix),
550/// so it surfaces as a [`HeaderMap`](ruststream::HeaderMap) entry on the delivered message.
551fn insert_meta_header(fields: &mut HashMap<String, Vec<u8>>, name: &str, value: u64) {
552    fields.insert(
553        format!("{HEADER_PREFIX}{name}"),
554        value.to_string().into_bytes(),
555    );
556}
557
558impl Subscriber for RedisSubscriber {
559    type Message = RedisMessage;
560    type Error = RedisError;
561
562    /// Yields one message per entry, refilling from Redis when the local buffer drains.
563    ///
564    /// # Cancel safety
565    ///
566    /// Dropping the returned stream between items is safe. Dropping it while a read is in flight
567    /// drops the read future; entries already delivered to this consumer but not yet acked stay in
568    /// the group's pending list, where the reclaim and claiming modes pick them up again.
569    fn stream(&mut self) -> impl Stream<Item = Result<Self::Message, Self::Error>> + Send + '_ {
570        unfold(self, |s| async move {
571            loop {
572                let _ = s.discard_stale();
573                if let Some(entry) = s.buffer.pop_front() {
574                    return Some((s.message(entry), s));
575                }
576                // An empty fetch (a blocking read that timed out) just loops and reads again.
577                if let Err(err) = s.fetch(PREFETCH).await {
578                    return Some((Err(err), s));
579                }
580            }
581        })
582    }
583}
584
585/// Repositioning the subscription moves its consumer group's cursor, which is shared by every
586/// consumer of that group; see [`RedisGroupSeeker`] for the full contract.
587impl Seekable for RedisSubscriber {
588    type Seeker = RedisGroupSeeker;
589
590    fn seeker(&self) -> RedisGroupSeeker {
591        (*self.seeker).clone()
592    }
593}
594
595impl BatchSubscriber for RedisSubscriber {
596    type Batch = Vec<RedisMessage>;
597
598    /// Yields one batch per non-empty read, native all the way down: `size` is the `COUNT` of the
599    /// `XREADGROUP` (or `XAUTOCLAIM`) that fetches it, so the server never sends more than the
600    /// batch holds. Never yields an empty batch.
601    ///
602    /// # Cancel safety
603    ///
604    /// Same as [`Subscriber::stream`]: dropping the stream mid-read leaves fetched-but-unacked
605    /// entries in the pending list.
606    fn batches(
607        &mut self,
608        size: NonZeroUsize,
609    ) -> impl Stream<Item = Result<Self::Batch, Self::Error>> + Send + '_ {
610        let count = u64::try_from(size.get()).unwrap_or(u64::MAX);
611        unfold(self, move |s| async move {
612            loop {
613                let _ = s.discard_stale();
614                if !s.buffer.is_empty() {
615                    // Move the entries out first so `s.message` can borrow `s` without overlapping
616                    // a live mutable borrow of `s.buffer`. The read already capped itself at
617                    // `count`; the split is what keeps a batch within `size` when a re-entered
618                    // subscription asks for a smaller one than the buffered read used.
619                    let tail = s.buffer.split_off(size.get().min(s.buffer.len()));
620                    let entries = std::mem::replace(&mut s.buffer, tail);
621                    let batch = entries
622                        .into_iter()
623                        .map(|entry| s.message(entry))
624                        .collect::<Result<Vec<_>, _>>();
625                    return Some((batch, s));
626                }
627                if let Err(err) = s.fetch(count).await {
628                    return Some((Err(err), s));
629                }
630            }
631        })
632    }
633}