ruststream_fred/stream.rs
1//! Builder describing one Redis Streams subscription.
2//!
3//! A subscription always reads through a consumer group. Three read modes are selected by
4//! constructor, never by a runtime flag, because they return disjoint message sets:
5//!
6//! * [`RedisStream::new`] reads fresh entries off the tail (`XREADGROUP > ...`).
7//! * [`RedisStream::reclaim`] reads stale pending entries another consumer never acked
8//! (`XAUTOCLAIM`, idle at least `min_idle`) - the crash-recovery path.
9//! * [`RedisStream::claiming`] reads both in one call (`XREADGROUP ... CLAIM`, Redis 8.4 and
10//! later): stale pending entries first, then fresh ones, each with its idle time and delivery
11//! count.
12//!
13//! Inferring the mode from a numeric parameter would be a footgun (a stray idle timeout could
14//! silently stop fresh delivery), so the mode is part of the constructor name. On a server older
15//! than 8.4, recovery is a separate `reclaim` subscriber beside the `new` one on the same group:
16//! "two handlers per group".
17
18use std::future::{Future, ready};
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::Duration;
21
22#[cfg(feature = "asyncapi")]
23use ruststream::asyncapi::Bindings;
24use ruststream::{
25 AddressedCopies, RedeliveryAddress, RedeliveryAddressed, RetryDeclaration, SubscriptionSource,
26};
27
28#[cfg(feature = "asyncapi")]
29use crate::asyncapi;
30use crate::broker::ConnectedRedisBroker;
31use crate::delay::{DelayConfig, DelayedRetry};
32use crate::{error::RedisError, subscriber::RedisSubscriber};
33
34/// This form's publish policy, [`RedisPublish`](crate::RedisPublish), under the mount-site name
35/// every form gives its own.
36pub use crate::publisher::RedisPublish as Publish;
37
38/// The same policy under the transactional mount-site name: a stream publisher buffers on the
39/// handle and owns transactions as it is, so the plain policy *is* the transactional one and
40/// there is no second type to transition to.
41pub use crate::publisher::RedisPublish as TransactionalPublish;
42
43/// The core prelude plus everything a Redis Streams service writes.
44///
45/// The broker, the [`RedisStream`] descriptor and its options, the group seek types with the
46/// delivery and batch contexts that carry them, this form's [`Publish`] policy, and the
47/// transaction, position and seek capabilities.
48///
49/// # Examples
50///
51/// ```
52/// # mod demo {
53/// use ruststream_fred::stream::prelude::*;
54/// use serde::{Deserialize, Serialize};
55///
56/// #[derive(Deserialize, Serialize, Outgoing)]
57/// #[outgoing(name = "orders.priced")]
58/// struct Order {
59/// id: u64,
60/// }
61///
62/// #[subscriber(RedisStream::new("orders").group("workers"), reply)]
63/// async fn price(orders: &[Order]) -> Result<Vec<Order>, HandlerOutcome> {
64/// Ok(orders.iter().map(|order| Order { id: order.id }).collect())
65/// }
66///
67/// #[ruststream::app]
68/// fn app() -> impl App {
69/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
70/// RedisBroker::standalone("redis://localhost:6379"),
71/// |b| {
72/// // One batch's replies land together, as one MULTI / EXEC block.
73/// b.include(price.batch(nonzero!(32)))
74/// .out_reply(TransactionalPublish)
75/// .transactional();
76/// },
77/// )
78/// }
79/// # }
80/// # fn main() {}
81/// ```
82///
83/// Two vocabularies that do not mix. A handler body imports `ruststream::prelude::*` and bounds an
84/// injected slot with the broker capability trait it needs (`Out<impl Publisher>`, `Out<impl
85/// TransactionalPublisher>`); a routes file globs this prelude and names the policy by its
86/// mount-site word, the same word on every form.
87///
88/// A file that also globs another form's prelude sees an ambiguous `Publish`; use
89/// [`crate::prelude`] and write `stream::Publish` there.
90pub mod prelude {
91 pub use ruststream::prelude::*;
92
93 pub use ruststream::{
94 OwnedTransactions, Positioned, Seeker, Transaction, TransactionalPublisher,
95 };
96
97 pub use super::{Publish, RedisStream, StreamStart, TransactionalPublish};
98 // `keys` arrives as the module, not as a glob: its members are short words a service also uses
99 // for its own types, and `Ctx<keys::SeekHandle>` reads as what it is at the use site.
100 pub use crate::context::{
101 PipelineContext, PoolContext, StreamBatchContext, StreamContext, keys,
102 };
103 pub use crate::pipeline::{Bindable, InRound, RedisPipelineSteps};
104 pub use crate::{
105 DelayedRetry, PARTITION_KEY_HEADER, RedisBroker, RedisGroupPosition, RedisGroupSeeker,
106 RedisPublishOptions, RedisPublishSteps, RedisSubscribeExt,
107 };
108
109 #[cfg(any(
110 feature = "tls-rustls",
111 feature = "tls-rustls-ring",
112 feature = "tls-native-tls"
113 ))]
114 pub use crate::{TlsConfig, TlsConnector};
115}
116
117const DEFAULT_BLOCK: Duration = Duration::from_secs(5);
118
119/// Generates an automatic consumer name when the caller does not set one. Distinct names keep
120/// each in-process subscriber's pending list separate within a shared group.
121fn auto_consumer() -> String {
122 static COUNTER: AtomicU64 = AtomicU64::new(0);
123 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
124 format!("ruststream-{n}")
125}
126
127/// Where a freshly created consumer group starts reading from. Only consulted when the group does
128/// not yet exist; an existing group keeps its own cursor.
129#[derive(Debug, Clone, Default)]
130pub enum StreamStart {
131 /// Only entries added after the group is created (`$`). The default.
132 #[default]
133 New,
134 /// Every entry currently in the stream (`0`).
135 Beginning,
136 /// A specific entry ID, exclusive.
137 Id(String),
138}
139
140impl StreamStart {
141 pub(crate) fn as_id(&self) -> &str {
142 match self {
143 Self::New => "$",
144 Self::Beginning => "0",
145 Self::Id(id) => id,
146 }
147 }
148}
149
150#[derive(Debug, Clone)]
151pub(crate) enum ReadMode {
152 /// `XREADGROUP >` - fresh tail.
153 Fresh,
154 /// `XAUTOCLAIM` of entries idle at least this long.
155 Reclaim { min_idle: Duration },
156 /// `XREADGROUP ... CLAIM` - entries idle at least this long, then the fresh tail, in one read.
157 Claiming { min_idle: Duration },
158}
159
160impl ReadMode {
161 /// How a retry redelivers on a subscription reading this way.
162 pub(crate) const fn requeue(&self) -> RequeueMode {
163 match self {
164 Self::Fresh | Self::Reclaim { .. } => RequeueMode::Republish,
165 Self::Claiming { min_idle } => RequeueMode::LeavePending {
166 min_idle: *min_idle,
167 },
168 }
169 }
170}
171
172/// What a retry does with an entry, which is a property of the read mode.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub(crate) enum RequeueMode {
175 /// Append a copy to the stream and ack the original. The retry is immediate, and the copy is
176 /// a new entry whose delivery count starts over.
177 Republish,
178 /// Leave the entry in the pending entries list. The subscription's own next read claims it
179 /// back once it has been idle `min_idle`, with the server's delivery count one higher, which
180 /// is how a claiming subscription retries without duplicating the entry - and what makes a
181 /// delayed retry native there, at that granularity.
182 LeavePending { min_idle: Duration },
183}
184
185/// Describes one Redis Streams subscription against a [`ConnectedRedisBroker`].
186///
187/// # Examples
188///
189/// ```
190/// # mod demo {
191/// use std::time::Duration;
192///
193/// use ruststream_fred::stream::prelude::*;
194/// use serde::Deserialize;
195///
196/// #[derive(Deserialize)]
197/// struct Order {
198/// id: u64,
199/// }
200///
201/// // Fresh tail: the normal worker, reading new entries.
202/// #[subscriber(RedisStream::new("orders").group("workers"))]
203/// async fn handle(order: &Order) -> HandlerOutcome {
204/// println!("order {}", order.id);
205/// HandlerOutcome::ack()
206/// }
207///
208/// // Recovery on the same group: entries a crashed worker left pending for over 30s.
209/// #[subscriber(RedisStream::reclaim("orders", Duration::from_secs(30)).group("workers"))]
210/// async fn recover(order: &Order) -> HandlerOutcome {
211/// println!("recovered order {}", order.id);
212/// HandlerOutcome::ack()
213/// }
214///
215/// #[ruststream::app]
216/// fn app() -> impl App {
217/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
218/// RedisBroker::standalone("redis://localhost:6379"),
219/// |b| {
220/// b.include(handle);
221/// b.include(recover);
222/// },
223/// )
224/// }
225/// # }
226/// # fn main() {}
227/// ```
228#[derive(Debug, Clone)]
229#[must_use]
230pub struct RedisStream {
231 key: String,
232 group: Option<String>,
233 consumer: Option<String>,
234 block: Option<Duration>,
235 start: StreamStart,
236 mode: ReadMode,
237 delayed_retry: Option<DelayedRetry>,
238 /// Where the mount site sends a spent delivery, taken from its retry declaration when the
239 /// subscription opens, so the broker writes that name as the stream it is.
240 dead_letter: Option<String>,
241}
242
243impl RedisStream {
244 /// A fresh-tail subscription on `key`: reads new entries via `XREADGROUP >`.
245 ///
246 /// A consumer group is required; set it with [`group`](Self::group).
247 pub fn new(key: impl Into<String>) -> Self {
248 Self {
249 key: key.into(),
250 group: None,
251 consumer: None,
252 block: None,
253 start: StreamStart::New,
254 mode: ReadMode::Fresh,
255 delayed_retry: None,
256 dead_letter: None,
257 }
258 }
259
260 /// A recovery subscription on `key`: reclaims pending entries idle at least `min_idle` via
261 /// `XAUTOCLAIM`. Run it alongside a [`new`](Self::new) subscriber on the same group to pick up
262 /// messages a consumer fetched but died before acking.
263 ///
264 /// `min_idle` has no default and must exceed the longest legitimate handler runtime: set it too
265 /// low and a healthy consumer's in-flight message gets reclaimed and processed twice.
266 pub fn reclaim(key: impl Into<String>, min_idle: Duration) -> Self {
267 Self {
268 key: key.into(),
269 group: None,
270 consumer: None,
271 block: None,
272 start: StreamStart::New,
273 mode: ReadMode::Reclaim { min_idle },
274 delayed_retry: None,
275 dead_letter: None,
276 }
277 }
278
279 /// A subscription on `key` that claims and reads in one call: `XREADGROUP ... CLAIM` first
280 /// takes over the group's entries idle at least `min_idle`, longest idle first, then fills the
281 /// rest of the read with fresh ones. One subscription, one consumer, one handler.
282 ///
283 /// Every delivery carries the server's own
284 /// [`IDLE_MS_HEADER`](crate::IDLE_MS_HEADER) and
285 /// [`DELIVERY_COUNT_HEADER`](crate::DELIVERY_COUNT_HEADER), both as of the delivery before
286 /// this one: a fresh entry reports zero for both, and the first claim back reports one earlier
287 /// delivery. The count is therefore the number of attempts that did not finish, which is what
288 /// a handler reads to give up on a message itself.
289 ///
290 /// This is Redis's own redelivery, so this mode is the one that settles a retry natively.
291 /// `retry()` leaves the entry in the pending entries list rather than appending a copy, so the
292 /// next read brings it back under its own id with the count one higher, no sooner than
293 /// `min_idle` from now. `retry_after(delay)` takes the same path and the entry comes back
294 /// after `min_idle`, so a delay shorter than that rounds up to it; name a
295 /// [`delayed_retry`](Self::delayed_retry) queue to have the delay honoured exactly instead.
296 /// The delivery reports its count to the runtime, which is what a `max_attempts` declaration
297 /// at the mount site caps.
298 ///
299 /// `min_idle` has no default and must exceed the longest legitimate handler runtime: below it,
300 /// a message a healthy consumer is still working on is taken away and processed twice.
301 ///
302 /// Redis 8.4.0 added the option. A registration of this mode against an older server refuses
303 /// to start, naming both versions; on such a server use [`new`](Self::new) with a
304 /// [`reclaim`](Self::reclaim) subscription beside it.
305 ///
306 /// # Examples
307 ///
308 /// ```
309 /// # mod demo {
310 /// use std::time::Duration;
311 ///
312 /// use ruststream_fred::stream::prelude::*;
313 /// use serde::Deserialize;
314 ///
315 /// #[derive(Deserialize)]
316 /// struct Order {
317 /// id: u64,
318 /// in_stock: bool,
319 /// }
320 ///
321 /// // New orders and the ones a dead worker took and never finished, in one subscription. A
322 /// // retry stays pending and comes back after 30s under its own id.
323 /// #[subscriber(RedisStream::claiming("orders", Duration::from_secs(30)).group("workers"))]
324 /// async fn handle(order: &Order) -> HandlerOutcome {
325 /// if !order.in_stock {
326 /// return HandlerOutcome::retry();
327 /// }
328 /// println!("order {}", order.id);
329 /// HandlerOutcome::ack()
330 /// }
331 ///
332 /// #[ruststream::app]
333 /// fn app() -> impl App {
334 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
335 /// RedisBroker::standalone("redis://localhost:6379"),
336 /// |b| {
337 /// // The server's delivery count feeds the cap.
338 /// b.include(handle)
339 /// .max_attempts(nonzero!(5u32))
340 /// .dead_letter("orders.dlq");
341 /// },
342 /// )
343 /// }
344 /// # }
345 /// # fn main() {}
346 /// ```
347 pub fn claiming(key: impl Into<String>, min_idle: Duration) -> Self {
348 Self {
349 key: key.into(),
350 group: None,
351 consumer: None,
352 block: None,
353 start: StreamStart::New,
354 mode: ReadMode::Claiming { min_idle },
355 delayed_retry: None,
356 dead_letter: None,
357 }
358 }
359
360 /// Sets the consumer group. Required for every subscription.
361 pub fn group(mut self, group: impl Into<String>) -> Self {
362 self.group = Some(group.into());
363 self
364 }
365
366 /// Sets this consumer's name within the group. Defaults to an auto-generated unique name.
367 pub fn consumer(mut self, consumer: impl Into<String>) -> Self {
368 self.consumer = Some(consumer.into());
369 self
370 }
371
372 /// How long one read blocks waiting for entries. Defaults to 5 seconds. In the fresh-tail and
373 /// claiming modes this is the `XREADGROUP` server-side block, which wakes on a new entry and,
374 /// under `CLAIM`, on an entry ageing past the threshold; in reclaim mode `XAUTOCLAIM` does not
375 /// block, so this is the poll interval slept between scans that find nothing to reclaim.
376 ///
377 /// Also reachable at the mount site, after the batch size, through
378 /// [`RedisSubscribeExt`](crate::RedisSubscribeExt).
379 pub const fn block(mut self, block: Duration) -> Self {
380 self.block = Some(block);
381 self
382 }
383
384 /// Where a newly created group starts reading. Ignored if the group already exists. Only
385 /// meaningful for the modes that read the tail, [`new`](Self::new) and
386 /// [`claiming`](Self::claiming).
387 pub fn start_id(mut self, start: StreamStart) -> Self {
388 self.start = start;
389 self
390 }
391
392 /// Opts this subscription into durable, crash-safe delayed retry backed by a ZSET delay queue.
393 ///
394 /// Off by default: without it, `retry_after(delay)` / `nack_after(delay)` degrade to the
395 /// runtime's broker-agnostic deferred re-publish, which the mount site binds with `out_retry`
396 /// and which is at-most-once over the delay window. With it, a delayed delivery is `ZADD`ed to
397 /// the named ZSET and replayed from there once due, so the retry survives a process crash. See
398 /// [`DelayedRetry`] for the key and TTL requirements.
399 ///
400 /// The sweeper that replays due entries runs inside this subscription's read loop, so its
401 /// granularity is the read [`block`](Self::block) interval.
402 pub fn delayed_retry(mut self, retry: DelayedRetry) -> Self {
403 self.delayed_retry = Some(retry);
404 self
405 }
406
407 /// The stream key this subscription reads.
408 #[must_use]
409 pub fn key(&self) -> &str {
410 &self.key
411 }
412
413 pub(crate) fn group_or_err(&self) -> Result<&str, RedisError> {
414 self.group.as_deref().ok_or_else(|| {
415 RedisError::InvalidOptions(format!(
416 "stream subscription on `{}` requires a consumer group: call .group(name)",
417 self.key
418 ))
419 })
420 }
421
422 pub(crate) fn consumer_or_auto(&self) -> String {
423 self.consumer.clone().unwrap_or_else(auto_consumer)
424 }
425
426 pub(crate) fn block_or_default(&self) -> Duration {
427 self.block.unwrap_or(DEFAULT_BLOCK)
428 }
429
430 pub(crate) const fn start(&self) -> &StreamStart {
431 &self.start
432 }
433
434 pub(crate) fn mode(&self) -> ReadMode {
435 self.mode.clone()
436 }
437
438 /// The dead-letter destination the mount site declared, once the registration has handed its
439 /// declaration over.
440 pub(crate) fn dead_letter(&self) -> Option<&str> {
441 self.dead_letter.as_deref()
442 }
443
444 /// Takes the dead-letter destination out of the mount site's declaration.
445 fn with_declaration(mut self, declaration: &RetryDeclaration) -> Self {
446 self.dead_letter = declaration.dead_letter().map(str::to_owned);
447 self
448 }
449
450 pub(crate) fn delay_config(&self) -> Option<DelayConfig> {
451 self.delayed_retry.as_ref().map(DelayConfig::from_retry)
452 }
453
454 /// Where a publisher reaches this subscription again, shared by both broker forms.
455 fn redelivery_key(&self) -> RedeliveryAddress {
456 RedeliveryAddress::new(self.key.clone())
457 }
458
459 /// What this subscription adds to its channel in the generated `AsyncAPI` document, shared by
460 /// both broker forms: the group and consumer it reads through, its read mode and, on the two
461 /// modes that claim, the idle threshold they claim at.
462 #[cfg(feature = "asyncapi")]
463 fn describe(&self) -> Bindings {
464 let (mode, min_idle) = match self.mode {
465 ReadMode::Fresh => (asyncapi::ReadMode::Fresh, None),
466 ReadMode::Reclaim { min_idle } => (asyncapi::ReadMode::Reclaim, Some(min_idle)),
467 ReadMode::Claiming { min_idle } => (asyncapi::ReadMode::Claiming, Some(min_idle)),
468 };
469 asyncapi::channel(&asyncapi::StreamSubscription::new(
470 self.group.as_deref(),
471 self.consumer.as_deref(),
472 mode,
473 min_idle.map(|idle| u64::try_from(idle.as_millis()).unwrap_or(u64::MAX)),
474 ))
475 }
476}
477
478impl SubscriptionSource<ConnectedRedisBroker> for RedisStream {
479 type Subscriber = RedisSubscriber;
480 /// A retry copy is an `XADD` this process makes, and the stream key is where it goes.
481 type Copies = AddressedCopies;
482
483 fn name(&self) -> &str {
484 self.key()
485 }
486
487 fn declare_retry(self, declaration: &RetryDeclaration) -> Self {
488 self.with_declaration(declaration)
489 }
490
491 async fn subscribe(
492 self,
493 connected: &ConnectedRedisBroker,
494 ) -> Result<Self::Subscriber, RedisError> {
495 connected.subscribe(self).await
496 }
497
498 #[cfg(feature = "asyncapi")]
499 fn channel_bindings(&self) -> Bindings {
500 self.describe()
501 }
502}
503
504/// The stream key, in every read mode: an `XADD` there is read by the consumer group this
505/// subscription belongs to.
506///
507/// Under [`reclaim`](RedisStream::reclaim) the copy is read by the group's tail reader rather
508/// than by this subscription, which only ever takes entries already pending on another consumer.
509/// That is the topology the mode is written for - a `reclaim` subscriber runs beside a
510/// [`new`](RedisStream::new) one on the same group - so the copy reaches the handler either way.
511impl RedeliveryAddressed<ConnectedRedisBroker> for RedisStream {
512 fn redelivery_address(
513 &self,
514 _connected: &ConnectedRedisBroker,
515 ) -> impl Future<Output = Result<RedeliveryAddress, RedisError>> {
516 ready(Ok(self.redelivery_key()))
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn group_is_required() {
526 let err = RedisStream::new("orders").group_or_err().unwrap_err();
527 assert!(matches!(err, RedisError::InvalidOptions(msg) if msg.contains("consumer group")));
528 }
529
530 #[test]
531 fn group_set_resolves() {
532 let s = RedisStream::new("orders").group("workers");
533 assert_eq!(s.group_or_err().expect("group set"), "workers");
534 }
535
536 #[test]
537 fn start_maps_to_redis_ids() {
538 assert_eq!(StreamStart::New.as_id(), "$");
539 assert_eq!(StreamStart::Beginning.as_id(), "0");
540 assert_eq!(StreamStart::Id("5-0".into()).as_id(), "5-0");
541 }
542
543 #[test]
544 fn reclaim_carries_min_idle() {
545 let s = RedisStream::reclaim("orders", Duration::from_secs(30)).group("g");
546 assert!(matches!(s.mode(), ReadMode::Reclaim { min_idle } if min_idle.as_secs() == 30));
547 }
548
549 #[test]
550 fn claiming_carries_min_idle() {
551 let s = RedisStream::claiming("orders", Duration::from_secs(30)).group("g");
552 assert!(matches!(s.mode(), ReadMode::Claiming { min_idle } if min_idle.as_secs() == 30));
553 }
554
555 /// Every read mode answers with the stream key: an `XADD` there is read by the group, which
556 /// is what a retry copy has to reach.
557 #[test]
558 fn every_read_mode_addresses_its_copies_to_the_stream_key() {
559 let idle = Duration::from_secs(30);
560 for source in [
561 RedisStream::new("orders"),
562 RedisStream::claiming("orders", idle),
563 RedisStream::reclaim("orders", idle),
564 ] {
565 assert_eq!(source.redelivery_key().as_str(), "orders");
566 }
567 }
568
569 /// The retry a read mode performs is the read mode's own: the claiming mode leaves the entry
570 /// pending so its next read claims it back, and the other two append a copy.
571 #[test]
572 fn the_claiming_mode_retries_by_leaving_the_entry_pending() {
573 let idle = Duration::from_secs(30);
574 assert_eq!(
575 RedisStream::claiming("orders", idle).mode().requeue(),
576 RequeueMode::LeavePending { min_idle: idle }
577 );
578 assert_eq!(
579 RedisStream::new("orders").mode().requeue(),
580 RequeueMode::Republish
581 );
582 assert_eq!(
583 RedisStream::reclaim("orders", idle).mode().requeue(),
584 RequeueMode::Republish
585 );
586 }
587}