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}