Skip to main content

ruststream_fred/
seek.rs

1//! Repositioning a consumer group over the stream: [`EntryId`], [`RedisGroupPosition`], and the
2//! [`RedisGroupSeeker`] handle behind the core `Seekable` capability.
3//!
4//! Redis Streams keep every entry until the stream is trimmed, so a consumer group can be moved
5//! back over history or forward past a region. The move is `XGROUP SETID`, which rewrites the
6//! group's cursor - and a group has one cursor, so the move applies to **every consumer of that
7//! group**, not just the subscription that asked. The type names say `Group` for exactly that
8//! reason; see [`RedisGroupSeeker`] for the full contract.
9
10use std::fmt::{Display, Formatter};
11use std::str::FromStr;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15use fred::clients::Pool;
16use fred::interfaces::StreamsInterface;
17use ruststream::{AckError, Seeker};
18
19use crate::connection::Connection;
20use crate::error::RedisError;
21
22/// A Redis Streams entry id: the `<milliseconds>-<sequence>` pair the server assigns to every
23/// entry.
24///
25/// Parsed on construction, so a value of this type is always a well-formed id and comparisons
26/// order entries the way the stream does. The `<milliseconds>` half alone is accepted too
27/// (`"1700000000000"` means `1700000000000-0`), matching what Redis accepts on the wire.
28///
29/// # Examples
30///
31/// ```
32/// # mod demo {
33/// use std::time::{Duration, SystemTime, UNIX_EPOCH};
34///
35/// use ruststream_fred::stream::prelude::*;
36/// use serde::Deserialize;
37///
38/// #[derive(Deserialize)]
39/// struct Quote {
40///     price: u64,
41/// }
42///
43/// // The id's first half is the server clock at `XADD`, so a quote's age needs no field of its own.
44/// #[subscriber(RedisStream::new("quotes").group("pricing"))]
45/// async fn price(quote: &Quote, Ctx(id): Ctx<keys::EntryId>) -> HandlerOutcome {
46///     let added = UNIX_EPOCH + Duration::from_millis(id.milliseconds());
47///     let age = SystemTime::now().duration_since(added).unwrap_or_default();
48///     if age > Duration::from_secs(5) {
49///         tracing::warn!(%id, age_ms = age.as_millis(), "stale quote");
50///         return HandlerOutcome::drop();
51///     }
52///     tracing::info!(quote.price, "quote");
53///     HandlerOutcome::ack()
54/// }
55///
56/// #[ruststream::app]
57/// fn app() -> impl App {
58///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
59///         RedisBroker::standalone("redis://localhost:6379"),
60///         |b| {
61///             b.include(price);
62///         },
63///     )
64/// }
65/// # }
66/// # fn main() {}
67/// ```
68#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub struct EntryId {
70    milliseconds: u64,
71    sequence: u64,
72}
73
74impl EntryId {
75    /// The lowest id the stream can express, below every real entry (Redis rejects `0-0` as an
76    /// entry id, so nothing can sit at it).
77    pub const ZERO: Self = Self::new(0, 0);
78
79    /// Builds an id from its two halves.
80    #[must_use]
81    pub const fn new(milliseconds: u64, sequence: u64) -> Self {
82        Self {
83            milliseconds,
84            sequence,
85        }
86    }
87
88    /// The `<milliseconds>` half: the entry's server-side timestamp.
89    #[must_use]
90    pub const fn milliseconds(&self) -> u64 {
91        self.milliseconds
92    }
93
94    /// The `<sequence>` half: the counter distinguishing entries added within one millisecond.
95    #[must_use]
96    pub const fn sequence(&self) -> u64 {
97        self.sequence
98    }
99
100    /// The id immediately below this one, saturating at [`ZERO`](Self::ZERO).
101    ///
102    /// Sequence numbers are dense within a millisecond, so the predecessor of `<ms>-0` is
103    /// `<ms - 1>-<u64::MAX>`. Used to turn "resume *at* this entry" into the exclusive cursor
104    /// `XGROUP SETID` takes.
105    #[must_use]
106    pub const fn previous(&self) -> Self {
107        match (self.milliseconds, self.sequence) {
108            (0, 0) => Self::ZERO,
109            (ms, 0) => Self::new(ms - 1, u64::MAX),
110            (ms, seq) => Self::new(ms, seq - 1),
111        }
112    }
113}
114
115impl Display for EntryId {
116    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
117        write!(f, "{}-{}", self.milliseconds, self.sequence)
118    }
119}
120
121impl FromStr for EntryId {
122    type Err = RedisError;
123
124    fn from_str(raw: &str) -> Result<Self, Self::Err> {
125        let invalid = || {
126            RedisError::InvalidOptions(format!(
127                "`{raw}` is not a redis stream entry id (expected `<milliseconds>-<sequence>`)"
128            ))
129        };
130        let (ms, seq) = match raw.split_once('-') {
131            Some((ms, seq)) => (ms, seq),
132            // Redis accepts a bare `<ms>`, which means `<ms>-0`.
133            None => (raw, "0"),
134        };
135        Ok(Self::new(
136            ms.parse().map_err(|_| invalid())?,
137            seq.parse().map_err(|_| invalid())?,
138        ))
139    }
140}
141
142/// Where a consumer group's cursor sits, for [`RedisGroupSeeker::seek`] and the
143/// `start_at(..)` clause of `#[subscriber]`.
144///
145/// The cursor is **group-wide**: it belongs to the consumer group, not to one subscription, so
146/// moving it moves it for every consumer reading that group.
147///
148/// # Examples
149///
150/// ```
151/// # mod demo {
152/// use ruststream_fred::EntryId;
153/// use ruststream_fred::stream::prelude::*;
154/// use serde::Deserialize;
155///
156/// #[derive(Deserialize)]
157/// struct Order {
158///     id: u64,
159///     // Set on the producer's resync marker: the last entry it still stands behind.
160///     resync_after: Option<String>,
161/// }
162///
163/// // Replays the whole retained stream into the `auditors` group on every start.
164/// #[subscriber(
165///     RedisStream::new("orders").group("auditors"),
166///     start_at(RedisGroupPosition::beginning())
167/// )]
168/// async fn audit(order: &Order) -> HandlerOutcome {
169///     tracing::info!(order.id, "audited");
170///     HandlerOutcome::ack()
171/// }
172///
173/// // On a resync marker the `workers` group resumes right after the named entry.
174/// #[subscriber(RedisStream::new("orders").group("workers"))]
175/// async fn work(order: &Order, Ctx(seeker): Ctx<keys::SeekHandle>) -> HandlerOutcome {
176///     let Some(checkpoint) = &order.resync_after else {
177///         return HandlerOutcome::ack();
178///     };
179///     let Ok(after) = checkpoint.parse::<EntryId>() else {
180///         return HandlerOutcome::drop();
181///     };
182///     match seeker.seek(RedisGroupPosition::after(after)).await {
183///         Ok(()) => HandlerOutcome::ack(),
184///         Err(_) => HandlerOutcome::retry(),
185///     }
186/// }
187///
188/// #[ruststream::app]
189/// fn app() -> impl App {
190///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
191///         RedisBroker::standalone("redis://localhost:6379"),
192///         |b| {
193///             b.include(audit);
194///             b.include(work);
195///         },
196///     )
197/// }
198/// # }
199/// # fn main() {}
200/// ```
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202#[non_exhaustive]
203pub enum RedisGroupPosition {
204    /// The oldest entry the stream still retains: the group replays everything it holds.
205    Beginning,
206    /// The tail: only entries added after the seek are delivered.
207    End,
208    /// The first entry *after* `id`. The cursor is exclusive, exactly like the id argument of
209    /// `XGROUP SETID`, so the entry at `id` itself is not delivered again.
210    After(EntryId),
211}
212
213impl RedisGroupPosition {
214    /// The oldest retained entry. Constructor form of [`Beginning`](Self::Beginning).
215    #[must_use]
216    pub const fn beginning() -> Self {
217        Self::Beginning
218    }
219
220    /// Only entries added from now on. Constructor form of [`End`](Self::End).
221    #[must_use]
222    pub const fn end() -> Self {
223        Self::End
224    }
225
226    /// Resume with the entry following `id`. Constructor form of [`After`](Self::After).
227    #[must_use]
228    pub const fn after(id: EntryId) -> Self {
229        Self::After(id)
230    }
231
232    /// The id `XGROUP SETID` takes for this position.
233    fn as_xid(self) -> String {
234        match self {
235            Self::Beginning => EntryId::ZERO.to_string(),
236            Self::End => "$".to_owned(),
237            Self::After(id) => id.to_string(),
238        }
239    }
240}
241
242/// Moves a consumer group's cursor.
243///
244/// The handle behind the core `Seekable` capability, minted with
245/// [`Seekable::seeker`](ruststream::Seekable::seeker) or read out of a delivery's context under
246/// the [`SeekHandle`](crate::context::keys::SeekHandle) key - as a
247/// `Ctx(seeker): Ctx<keys::SeekHandle>` handler parameter, or with
248/// `ctx.context(keys::SeekHandle)`.
249///
250/// # A seek is group-wide
251///
252/// Redis keeps one cursor per consumer group, so `XGROUP SETID` moves the read position for
253/// **every consumer of that group**, not only for the subscription this seeker came from. That is
254/// unlike a partitioned log, where a seek is scoped to one consumer instance. Treat a seek as an
255/// operation on the group: replaying a range replays it for the whole worker pool, and skipping
256/// forward skips for all of them.
257///
258/// # What a seek does not do
259///
260/// * It does not clear the pending entries list. Entries already delivered and not acknowledged
261///   stay pending and remain reachable through the reclaim path
262///   ([`RedisStream::reclaim`](crate::RedisStream::reclaim)), whichever way the cursor moved.
263/// * It does not cancel delayed retries. Copies already scheduled in a
264///   [`DelayedRetry::DurableZset`](crate::DelayedRetry::DurableZset) queue are keyed by their due
265///   time, not by the cursor, so they are appended to the stream when they fall due regardless of
266///   where the group is reading.
267/// * It does not carry the native delivery count over. A replayed entry is read as a new
268///   delivery, and Redis counts it from one again, so a `max_attempts` declaration at the mount
269///   site that reads this count caps from the replay on; the framework retry-count header only
270///   moves on an actual retry.
271///
272/// # Timing
273///
274/// The cursor changes as soon as `seek` returns, but a subscription parked in a blocking
275/// `XREADGROUP` observes it only on its next read - within one
276/// [`RedisStream::block`](crate::RedisStream::block) interval. Entries selected under the old
277/// cursor are discarded rather than delivered, so a seek never yields a message from the position
278/// it moved away from.
279///
280/// # Examples
281///
282/// ```
283/// # mod demo {
284/// use ruststream_fred::stream::prelude::*;
285/// use serde::Deserialize;
286///
287/// #[derive(Deserialize)]
288/// struct Event {
289///     kind: String,
290/// }
291///
292/// // A `rebuild` event makes the projection group read the whole retained stream again.
293/// #[subscriber(RedisStream::new("events").group("projection"))]
294/// async fn project(event: &Event, Ctx(seeker): Ctx<keys::SeekHandle>) -> HandlerOutcome {
295///     if event.kind != "rebuild" {
296///         tracing::info!(event.kind, "projecting");
297///         return HandlerOutcome::ack();
298///     }
299///     match seeker.seek(RedisGroupPosition::beginning()).await {
300///         Ok(()) => HandlerOutcome::ack(),
301///         Err(_) => HandlerOutcome::retry(),
302///     }
303/// }
304///
305/// #[ruststream::app]
306/// fn app() -> impl App {
307///     RustStream::new(AppInfo::new("projection", "0.1.0")).with_broker(
308///         RedisBroker::standalone("redis://localhost:6379"),
309///         |b| {
310///             b.include(project);
311///         },
312///     )
313/// }
314/// # }
315/// # fn main() {}
316/// ```
317#[derive(Clone)]
318pub struct RedisGroupSeeker {
319    /// Asked before a seek, which after the broker's shutdown refuses at once.
320    connection: Arc<Connection>,
321    /// The same pool, kept at hand for the delivery contexts that give it to a handler.
322    pool: Pool,
323    // `Arc<str>` rather than `String`: every delivery's context clones this handle, so the clone
324    // has to stay allocation-free on the dispatch path.
325    key: Arc<str>,
326    group: Arc<str>,
327    /// Shared with the subscription: bumped on every seek so entries selected under the old
328    /// cursor are recognised as stale and dropped instead of delivered.
329    generation: Arc<AtomicU64>,
330}
331
332impl std::fmt::Debug for RedisGroupSeeker {
333    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
334        f.debug_struct("RedisGroupSeeker")
335            .field("key", &self.key)
336            .field("group", &self.group)
337            .finish_non_exhaustive()
338    }
339}
340
341impl RedisGroupSeeker {
342    pub(crate) fn new(
343        connection: Arc<Connection>,
344        key: impl Into<Arc<str>>,
345        group: impl Into<Arc<str>>,
346        generation: Arc<AtomicU64>,
347    ) -> Self {
348        Self {
349            pool: connection.pool().clone(),
350            connection,
351            key: key.into(),
352            group: group.into(),
353            generation,
354        }
355    }
356
357    /// The connection pool this handle issues its commands on.
358    pub(crate) const fn pool(&self) -> &Pool {
359        &self.pool
360    }
361
362    /// Refuses a settlement of one of this subscription's deliveries once the broker shut down.
363    ///
364    /// Asked through the seeker because every delivery already carries it: the check adds nothing
365    /// to what a delivery holds.
366    #[inline]
367    pub(crate) fn ensure_open_to_settle(&self) -> Result<(), AckError> {
368        self.connection.ensure_open_to_settle()
369    }
370
371    /// The stream key this handle repositions a group over.
372    #[must_use]
373    pub fn key(&self) -> &str {
374        &self.key
375    }
376
377    /// The consumer group whose cursor this handle moves.
378    ///
379    /// The cursor belongs to the group, so this names everything a
380    /// [`seek`](Seeker::seek) through this handle repositions.
381    #[must_use]
382    pub fn group(&self) -> &str {
383        &self.group
384    }
385}
386
387impl Seeker for RedisGroupSeeker {
388    type Position = RedisGroupPosition;
389    type Error = RedisError;
390
391    /// Moves the group cursor with `XGROUP SETID`.
392    ///
393    /// # Errors
394    ///
395    /// Returns [`RedisError::Stream`] when the group or the stream does not exist, or the
396    /// command fails, and [`RedisError::ShutDown`] once the broker was shut down.
397    async fn seek(&self, to: RedisGroupPosition) -> Result<(), RedisError> {
398        self.connection.ensure_open()?;
399        let _: String = self
400            .pool
401            .xgroup_setid(&*self.key, &*self.group, to.as_xid())
402            .await
403            .map_err(RedisError::stream)?;
404        // Bumped after the cursor moved, so a reader that observes the new generation is
405        // guaranteed to read under the new cursor.
406        self.generation.fetch_add(1, Ordering::Release);
407        Ok(())
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn entry_ids_parse_and_render() {
417        let id: EntryId = "1700000000000-4".parse().expect("valid id");
418        assert_eq!(id, EntryId::new(1_700_000_000_000, 4));
419        assert_eq!(id.to_string(), "1700000000000-4");
420        // A bare millisecond half means sequence zero, as on the wire.
421        assert_eq!(
422            "5".parse::<EntryId>().expect("valid id"),
423            EntryId::new(5, 0)
424        );
425    }
426
427    #[test]
428    fn malformed_entry_ids_are_rejected() {
429        for raw in ["", "-", "abc", "5-x", "5-6-7"] {
430            assert!(
431                raw.parse::<EntryId>().is_err(),
432                "`{raw}` must not parse as an entry id"
433            );
434        }
435    }
436
437    // The predecessor is what turns a captured delivery into the exclusive cursor that redelivers
438    // it, so the sequence and millisecond borrows must both be right.
439    #[test]
440    fn previous_walks_back_one_id() {
441        assert_eq!(EntryId::new(5, 3).previous(), EntryId::new(5, 2));
442        assert_eq!(EntryId::new(5, 0).previous(), EntryId::new(4, u64::MAX));
443        assert_eq!(EntryId::ZERO.previous(), EntryId::ZERO);
444        assert_eq!(EntryId::new(0, 1).previous(), EntryId::ZERO);
445    }
446
447    #[test]
448    fn positions_map_to_setid_arguments() {
449        assert_eq!(RedisGroupPosition::beginning().as_xid(), "0-0");
450        assert_eq!(RedisGroupPosition::end().as_xid(), "$");
451        assert_eq!(
452            RedisGroupPosition::after(EntryId::new(7, 2)).as_xid(),
453            "7-2"
454        );
455    }
456}