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