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}