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}