ruststream_fred/list.rs
1//! Redis lists: a work queue whose every entry goes to one consumer.
2//!
3//! - [Lists][lists]: [`RedisList`], the simple form, at-most-once.
4//! - [reliable][reliable]: [`RedisList::reliable`], at-least-once through a processing list.
5//! - [Recovery][recovery]: [`RedisList::recovery_zset`], the watchdog that returns a dead
6//! consumer's entries.
7//! - [processing][processing]: [`RedisList::processing`], and the hash tag a cluster needs.
8//! - [Framing][framing]: [`RedisList::codec`], the frame the headers travel in.
9//! - [RedisListPublish][publish]: [`Publish`], `LPUSH` with an optional key expiry.
10//! - [Prelude][prelude]: [`prelude`], everything a list service imports.
11//!
12//! [lists]: https://powersemmi.github.io/ruststream-fred/latest/components/lists/
13//! [reliable]: https://powersemmi.github.io/ruststream-fred/latest/components/lists/reliable/
14//! [recovery]: https://powersemmi.github.io/ruststream-fred/latest/components/lists/recovery/
15//! [processing]: https://powersemmi.github.io/ruststream-fred/latest/components/lists/processing/
16//! [framing]: https://powersemmi.github.io/ruststream-fred/latest/components/subscriptions/framing/
17//! [publish]: https://powersemmi.github.io/ruststream-fred/latest/components/publishing/list-publish/
18//! [prelude]: https://powersemmi.github.io/ruststream-fred/latest/components/broker/prelude/
19
20use std::collections::VecDeque;
21use std::fmt::{Debug, Formatter};
22use std::future::{Future, ready};
23use std::num::NonZeroUsize;
24use std::sync::Arc;
25use std::time::Duration;
26
27use bytes::Bytes;
28use fred::clients::{Client, Pool};
29use fred::error::ErrorKind;
30use fred::interfaces::{KeysInterface, ListInterface, SortedSetsInterface};
31use fred::types::Value;
32use fred::types::lists::LMoveDirection;
33use futures::Stream;
34use futures::stream::unfold;
35#[cfg(feature = "asyncapi")]
36use ruststream::asyncapi::Bindings;
37use ruststream::codec::Codec;
38use ruststream::runtime::IntoSource;
39use ruststream::{
40 AckError, AddressedCopies, BatchSubscriber, BufferedSubscriber, HeaderMap, IncomingMessage,
41 Lend, PairError, Partitioned, PublishPolicy, RedeliveryAddress, RedeliveryAddressed,
42 RetryDeclaration, SubscriptionSource,
43};
44
45use crate::broker::{ConnectedRedisBroker, RedisCore};
46use crate::connection::{Connection, ReadConnection};
47use crate::envelope::{SharedEnvelope, frame, unframe};
48use crate::loopback::{InFlight, Loopback, Tap};
49use crate::partition::{RedisPublishOptions, resolved_headers};
50use crate::pipeline::{ListForm, RoundMessage, Window};
51use crate::publisher::joins_round;
52use crate::recovery::{self, RecoveryConfig};
53use crate::route::Route;
54use crate::{error::RedisError, message::PARTITION_KEY_HEADER};
55
56/// This form's publish policy, [`RedisListPublish`], under the mount-site name every form gives
57/// its own. Its options, the framing codec and the key TTL, still chain off it.
58pub use crate::list::RedisListPublish as Publish;
59
60/// The core prelude, the broker, the [`RedisList`] descriptor and this form's [`Publish`] policy.
61/// A list carries no core capability traits.
62///
63/// # Examples
64///
65/// ```
66/// # mod demo {
67/// use ruststream_fred::list::prelude::*;
68/// use serde::{Deserialize, Serialize};
69///
70/// #[derive(Deserialize)]
71/// struct Job {
72/// id: u64,
73/// }
74///
75/// #[derive(Serialize, Outgoing)]
76/// #[outgoing(name = "jobs.done")]
77/// struct Done {
78/// id: u64,
79/// }
80///
81/// #[subscriber(RedisList::new("jobs").reliable(), reply)]
82/// async fn run_job(job: &Job) -> Done {
83/// Done { id: job.id }
84/// }
85///
86/// #[ruststream::app]
87/// fn app() -> impl App {
88/// RustStream::new(AppInfo::new("jobs", "0.1.0")).with_broker(
89/// RedisBroker::standalone("redis://localhost:6379"),
90/// |b| {
91/// // The reply is `LPUSH`ed onto `jobs.done`, a queue of its own.
92/// b.include(run_job).out_reply(Publish::default());
93/// },
94/// )
95/// }
96/// # }
97/// # fn main() {}
98/// ```
99///
100/// Two vocabularies that do not mix. A handler body imports `ruststream::prelude::*` and bounds an
101/// injected slot with the broker capability trait it needs (`Out<impl Publisher>`); a routes file
102/// globs this prelude and names the policy by its mount-site word, the same word on every form.
103///
104/// The
105/// [Prelude](https://powersemmi.github.io/ruststream-fred/latest/components/broker/prelude/)
106/// page shows each form's prelude in a service.
107///
108/// A file that also globs another form's prelude sees an ambiguous `Publish`; use
109/// [`crate::prelude`] and write `list::Publish` there.
110pub mod prelude {
111 pub use ruststream::prelude::*;
112
113 pub use super::{Publish, RedisList};
114 // `keys` arrives as the module, not as a glob: its members are short words a service also uses
115 // for its own types, and `Ctx<keys::FredPool>` reads as what it is at the use site.
116 pub use crate::context::{PipelineContext, PoolContext, keys};
117 pub use crate::pipeline::{Bindable, InRound, RedisPipelineSteps};
118 pub use crate::{
119 PARTITION_KEY_HEADER, RedisBroker, RedisPublishOptions, RedisPublishSteps,
120 RedisSubscribeExt,
121 };
122
123 #[cfg(any(
124 feature = "tls-rustls",
125 feature = "tls-rustls-ring",
126 feature = "tls-native-tls"
127 ))]
128 pub use crate::{TlsConfig, TlsConnector};
129}
130
131const DEFAULT_BLOCK: Duration = Duration::from_secs(5);
132/// Suffix appended to the list key to form the default processing list (reliable mode).
133const PROCESSING_SUFFIX: &str = ".processing";
134
135fn block_secs(block: Duration) -> f64 {
136 block.as_secs_f64()
137}
138
139/// Normalizes a blocking pop (`BRPOP` / `BLMOVE`) result: fred reports a timed-out pop with nothing
140/// available as a timeout error rather than an empty reply, so treat that as "no entry this round"
141/// and let the read loop retry. Any other error propagates.
142fn empty_on_timeout<T>(
143 result: Result<Option<T>, fred::error::Error>,
144) -> Result<Option<T>, RedisError> {
145 match result {
146 Ok(value) => Ok(value),
147 Err(err) if matches!(err.kind(), ErrorKind::Timeout) => Ok(None),
148 Err(err) => Err(RedisError::stream(err)),
149 }
150}
151
152/// Describes one list subscription against a [`ConnectedRedisBroker`].
153///
154/// # Examples
155///
156/// ```
157/// # mod demo {
158/// use std::time::Duration;
159///
160/// use ruststream_fred::list::prelude::*;
161/// use serde::Deserialize;
162///
163/// #[derive(Deserialize)]
164/// struct Job {
165/// id: u64,
166/// }
167///
168/// // Reliable: the entry waits on a processing list until the handler acknowledges it.
169/// #[subscriber(RedisList::new("jobs").reliable().block(Duration::from_secs(2)))]
170/// async fn run_job(job: &Job) -> HandlerOutcome {
171/// tracing::info!(job.id, "running");
172/// HandlerOutcome::ack()
173/// }
174///
175/// // Simple: popped and gone, so a crash mid-handler loses the entry.
176/// #[subscriber(RedisList::new("metrics"))]
177/// async fn record(job: &Job) -> HandlerOutcome {
178/// tracing::info!(job.id, "recorded");
179/// HandlerOutcome::ack()
180/// }
181///
182/// #[ruststream::app]
183/// fn app() -> impl App {
184/// RustStream::new(AppInfo::new("jobs", "0.1.0")).with_broker(
185/// RedisBroker::standalone("redis://localhost:6379"),
186/// |b| {
187/// b.include(run_job);
188/// b.include(record);
189/// },
190/// )
191/// }
192/// # }
193/// # fn main() {}
194/// ```
195#[derive(Clone)]
196#[must_use]
197pub struct RedisList {
198 key: String,
199 reliable: bool,
200 processing: Option<String>,
201 block: Option<Duration>,
202 codec: Option<SharedEnvelope>,
203 min_idle: Option<Duration>,
204 recovery_zset: Option<String>,
205 recovery_ttl: Option<Duration>,
206 /// Where the mount site sends a spent delivery, taken from its retry declaration when the
207 /// subscription opens, so the broker writes that name as the list it is.
208 dead_letter: Option<String>,
209}
210
211impl Debug for RedisList {
212 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
213 f.debug_struct("RedisList")
214 .field("key", &self.key)
215 .field("reliable", &self.reliable)
216 .field("processing", &self.processing)
217 .field("codec", &self.codec.is_some())
218 .field("recovery_zset", &self.recovery_zset)
219 .field("recovery_ttl", &self.recovery_ttl)
220 .field("dead_letter", &self.dead_letter)
221 .finish_non_exhaustive()
222 }
223}
224
225impl RedisList {
226 /// A simple (at-most-once) `BRPOP` work-queue consumer on `key`.
227 pub fn new(key: impl Into<String>) -> Self {
228 Self {
229 key: key.into(),
230 reliable: false,
231 processing: None,
232 block: None,
233 codec: None,
234 min_idle: None,
235 recovery_zset: None,
236 recovery_ttl: None,
237 dead_letter: None,
238 }
239 }
240
241 /// Switches to reliable (at-least-once) mode: entries move to a processing list and are removed
242 /// on `ack`.
243 pub const fn reliable(mut self) -> Self {
244 self.reliable = true;
245 self
246 }
247
248 /// Sets the processing-list key used in reliable mode. Defaults to `<key>.processing`.
249 pub fn processing(mut self, key: impl Into<String>) -> Self {
250 self.processing = Some(key.into());
251 self
252 }
253
254 /// How long one blocking pop waits before looping. Defaults to 5 seconds.
255 ///
256 /// Also reachable at the mount site, after the batch size, through
257 /// [`RedisSubscribeExt`](crate::RedisSubscribeExt).
258 pub const fn block(mut self, block: Duration) -> Self {
259 self.block = Some(block);
260 self
261 }
262
263 /// Decodes the header/payload envelope with `codec` (must match the publisher). Without it the
264 /// default binary framing is used. Either way the payload arrives as it was published.
265 pub fn codec(mut self, codec: impl Codec + 'static) -> Self {
266 self.codec = Some(Arc::new(codec));
267 self
268 }
269
270 /// How long a claimed reliable-mode entry may sit idle on the processing list before the
271 /// recovery watchdog returns it to the main list. Required for (and only meaningful with)
272 /// [`recovery_zset`](Self::recovery_zset).
273 ///
274 /// It has no default and must exceed the longest legitimate handler runtime: set it too low and
275 /// a healthy consumer's in-flight entry gets recovered and processed twice.
276 pub const fn min_idle(mut self, min_idle: Duration) -> Self {
277 self.min_idle = Some(min_idle);
278 self
279 }
280
281 /// Opts reliable mode into orphan recovery, naming the ZSET key that tracks in-flight claims.
282 ///
283 /// Off by default (a dead consumer's entry stays stranded on the processing list). The key has
284 /// no sane default, so it is named explicitly here; pair it with [`min_idle`](Self::min_idle),
285 /// which is required when recovery is on. Reliable mode is implied. The
286 /// [Recovery](https://powersemmi.github.io/ruststream-fred/latest/components/lists/recovery/)
287 /// page shows the watchdog in a service.
288 ///
289 /// # Examples
290 ///
291 /// ```
292 /// # mod demo {
293 /// use std::time::Duration;
294 ///
295 /// use ruststream_fred::list::prelude::*;
296 /// use serde::Deserialize;
297 ///
298 /// #[derive(Deserialize)]
299 /// struct Job {
300 /// id: u64,
301 /// }
302 ///
303 /// // At-least-once, with the watchdog that returns a dead consumer's entry to the queue. Set
304 /// // `min_idle` above the longest handler runtime, or a running job is recovered and repeated.
305 /// #[subscriber(
306 /// RedisList::new("jobs")
307 /// .reliable()
308 /// .min_idle(Duration::from_secs(30))
309 /// .recovery_zset("jobs.inflight")
310 /// )]
311 /// async fn run_job(job: &Job) -> HandlerOutcome {
312 /// tracing::info!(job.id, "running");
313 /// HandlerOutcome::ack()
314 /// }
315 ///
316 /// #[ruststream::app]
317 /// fn app() -> impl App {
318 /// RustStream::new(AppInfo::new("jobs", "0.1.0")).with_broker(
319 /// RedisBroker::standalone("redis://localhost:6379"),
320 /// |b| {
321 /// b.include(run_job);
322 /// },
323 /// )
324 /// }
325 /// # }
326 /// # fn main() {}
327 /// ```
328 pub fn recovery_zset(mut self, key: impl Into<String>) -> Self {
329 self.recovery_zset = Some(key.into());
330 self.reliable = true;
331 self
332 }
333
334 /// An optional auto-cleanup TTL on the recovery ZSET key (refreshed on every claim).
335 ///
336 /// When set it must exceed [`min_idle`](Self::min_idle) (and the longest legitimate handler
337 /// runtime), or in-flight tracking is dropped before the watchdog can act.
338 pub const fn recovery_ttl(mut self, ttl: Duration) -> Self {
339 self.recovery_ttl = Some(ttl);
340 self
341 }
342
343 /// The list key this subscription consumes.
344 #[must_use]
345 pub fn key(&self) -> &str {
346 &self.key
347 }
348
349 /// Consumes the definition for its key, so opening a subscription moves the string out
350 /// instead of copying it.
351 pub(crate) fn into_key(self) -> String {
352 self.key
353 }
354
355 pub(crate) const fn is_reliable(&self) -> bool {
356 self.reliable
357 }
358
359 pub(crate) fn processing_or_default(&self) -> String {
360 self.processing
361 .clone()
362 .unwrap_or_else(|| format!("{}{PROCESSING_SUFFIX}", self.key))
363 }
364
365 pub(crate) fn block_or_default(&self) -> Duration {
366 self.block.unwrap_or(DEFAULT_BLOCK)
367 }
368
369 pub(crate) fn codec_handle(&self) -> Option<SharedEnvelope> {
370 self.codec.clone()
371 }
372
373 /// How a name this subscription reads or dead-letters to is written: `LPUSH`, in its framing.
374 pub(crate) fn route(&self) -> Route {
375 Route::List {
376 envelope: self.codec.clone(),
377 }
378 }
379
380 /// The dead-letter destination the mount site declared, once the registration has handed its
381 /// declaration over.
382 pub(crate) fn dead_letter(&self) -> Option<&str> {
383 self.dead_letter.as_deref()
384 }
385
386 /// Takes the dead-letter destination out of the mount site's declaration.
387 fn with_declaration(mut self, declaration: &RetryDeclaration) -> Self {
388 self.dead_letter = declaration.dead_letter().map(str::to_owned);
389 self
390 }
391
392 /// What this subscription adds to its channel in the generated `AsyncAPI` document, shared by
393 /// both broker forms: whether it acknowledges, the processing list it holds claims on, and
394 /// how headers are framed beside the payload.
395 #[cfg(feature = "asyncapi")]
396 fn describe(&self) -> Bindings {
397 let processing = self.reliable.then(|| self.processing_or_default());
398 crate::asyncapi::channel(&crate::asyncapi::ListSubscription::new(
399 self.reliable,
400 processing.as_deref(),
401 crate::asyncapi::Envelope::of(self.codec.as_ref()),
402 ))
403 }
404
405 /// Resolves the recovery settings, or `None` when recovery was not opted into.
406 ///
407 /// # Errors
408 ///
409 /// Returns [`RedisError::InvalidOptions`] when a recovery ZSET is named without a
410 /// [`min_idle`](Self::min_idle), which has no sane default.
411 pub(crate) fn recovery_config(&self) -> Result<Option<RecoveryConfig>, RedisError> {
412 let Some(zset_key) = self.recovery_zset.clone() else {
413 return Ok(None);
414 };
415 let min_idle = self.min_idle.ok_or_else(|| {
416 RedisError::InvalidOptions(format!(
417 "reliable list recovery on `{}` needs a min_idle: call .min_idle(duration) \
418 alongside .recovery_zset(key)",
419 self.key
420 ))
421 })?;
422 Ok(Some(RecoveryConfig {
423 zset_key,
424 min_idle,
425 ttl: self.recovery_ttl,
426 loopback: Loopback::default(),
427 }))
428 }
429}
430
431// The manual path binds a `Handle` body to its source with `subscriber(source, body)`, which
432// takes the descriptor as it is, the way the `#[subscriber(..)]` attribute does.
433impl IntoSource for RedisList {
434 type Source = Self;
435
436 fn into_source(self) -> Self {
437 self
438 }
439}
440
441impl SubscriptionSource<ConnectedRedisBroker> for RedisList {
442 type Subscriber = RedisListSubscriber;
443 /// A retry copy is an `LPUSH` this process makes, and the list key is where it goes.
444 type Copies = AddressedCopies;
445
446 fn name(&self) -> &str {
447 self.key()
448 }
449
450 fn declare_retry(self, declaration: &RetryDeclaration) -> Self {
451 self.with_declaration(declaration)
452 }
453
454 async fn subscribe(
455 self,
456 connected: &ConnectedRedisBroker,
457 ) -> Result<Self::Subscriber, RedisError> {
458 connected.subscribe_list(self).await
459 }
460
461 #[cfg(feature = "asyncapi")]
462 fn channel_bindings(&self) -> Bindings {
463 self.describe()
464 }
465}
466
467/// The list key: the subscription pops from it and [`RedisListPublish`] pushes onto it, so a
468/// deferred copy lands in the same queue the delivery came from.
469impl RedeliveryAddressed<ConnectedRedisBroker> for RedisList {
470 fn redelivery_address(
471 &self,
472 _connected: &ConnectedRedisBroker,
473 ) -> impl Future<Output = Result<RedeliveryAddress, RedisError>> {
474 ready(Ok(RedeliveryAddress::new(self.key.clone())))
475 }
476}
477
478/// A list-backed work-queue subscription.
479///
480/// A pop returns one entry, so the batches a [`BatchSubscriber`] hands out are assembled on the
481/// client by the core's [`BufferedSubscriber`]: it fills a batch up to the size the mount site
482/// asked for and closes a partial one on its own deadline.
483pub struct RedisListSubscriber(BufferedSubscriber<ListWire>);
484
485impl Debug for RedisListSubscriber {
486 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
487 f.debug_tuple("RedisListSubscriber").field(&self.0).finish()
488 }
489}
490
491impl RedisListSubscriber {
492 pub(crate) fn new(wire: ListWire) -> Self {
493 Self(BufferedSubscriber::new(wire))
494 }
495}
496
497impl ruststream::Subscriber for RedisListSubscriber {
498 type Message = RedisListMessage;
499 type Error = RedisError;
500
501 /// Yields one message per popped entry.
502 ///
503 /// # Cancel safety
504 ///
505 /// Dropping the returned stream between items is safe. In reliable mode an entry already moved
506 /// to the processing list but not yet settled stays there until acked or recovered manually.
507 fn stream(&mut self) -> impl Stream<Item = Result<Self::Message, Self::Error>> + Send + '_ {
508 self.0.stream()
509 }
510}
511
512impl BatchSubscriber for RedisListSubscriber {
513 type Batch = Vec<RedisListMessage>;
514
515 /// Yields batches of at most `size` entries, assembled from consecutive pops.
516 ///
517 /// # Cancel safety
518 ///
519 /// Same as [`Subscriber::stream`](ruststream::Subscriber::stream). Dropping the stream while a
520 /// batch is filling abandons the entries it holds unsettled; in reliable mode they stay on the
521 /// processing list until recovered.
522 fn batches(
523 &mut self,
524 size: NonZeroUsize,
525 ) -> impl Stream<Item = Result<Self::Batch, Self::Error>> + Send + '_ {
526 self.0.batches(size)
527 }
528}
529
530/// The wire side of a list subscription: one blocking pop per delivery.
531pub(crate) struct ListWire {
532 connection: Arc<Connection>,
533 /// The connection every pop goes out on, apart from the pool: a blocking pop holds its
534 /// connection until it returns.
535 reader: ReadConnection,
536 key: String,
537 reliable: bool,
538 processing: String,
539 block: Duration,
540 codec: Option<SharedEnvelope>,
541 recovery: Option<RecoveryConfig>,
542 /// The subscription's registration with the in-process server; empty on a real connection.
543 tap: Tap,
544}
545
546impl Debug for ListWire {
547 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
548 f.debug_struct("ListWire")
549 .field("key", &self.key)
550 .field("reliable", &self.reliable)
551 .field("recovery", &self.recovery.is_some())
552 .finish_non_exhaustive()
553 }
554}
555
556impl ListWire {
557 #[allow(
558 clippy::too_many_arguments,
559 reason = "internal constructor mirroring the descriptor"
560 )]
561 pub(crate) fn new(
562 connection: Arc<Connection>,
563 reader: ReadConnection,
564 key: String,
565 reliable: bool,
566 processing: String,
567 block: Duration,
568 codec: Option<SharedEnvelope>,
569 recovery: Option<RecoveryConfig>,
570 tap: Tap,
571 ) -> Self {
572 Self {
573 connection,
574 reader,
575 key,
576 reliable,
577 processing,
578 block,
579 codec,
580 recovery,
581 tap,
582 }
583 }
584
585 fn simple_message(&self, raw: &[u8]) -> RedisListMessage {
586 let (payload, headers) = unframe(self.codec.as_ref(), raw);
587 RedisListMessage {
588 payload,
589 headers,
590 ack: None,
591 connection: Arc::clone(&self.connection),
592 flight: self.tap.delivered(),
593 }
594 }
595
596 fn reliable_message(&self, raw: Vec<u8>, recovery: Option<RecoveryHandle>) -> RedisListMessage {
597 let (payload, headers) = unframe(self.codec.as_ref(), &raw);
598 RedisListMessage {
599 payload,
600 headers,
601 connection: Arc::clone(&self.connection),
602 ack: Some(ListAck {
603 main_key: self.key.clone(),
604 processing_key: self.processing.clone(),
605 value: raw,
606 recovery,
607 }),
608 flight: self.tap.delivered(),
609 }
610 }
611
612 /// Blocks for the next entry, returning `None` when the pop times out (the caller loops). When
613 /// recovery is enabled, first returns any orphaned entries to the main list so this same pop can
614 /// pick them up.
615 async fn next_entry(&self) -> Result<Option<RedisListMessage>, RedisError> {
616 // In process the pop below cannot block, so its wait is taken here, before the recovery
617 // sweep, which then finds what went stale while it waited.
618 #[cfg(feature = "testing")]
619 self.tap.readable().await;
620 // The read connection is the subscription's own, so the broker's shutdown does not close
621 // it: a read after the shutdown refuses here instead of delivering past it.
622 self.connection.ensure_open()?;
623 let secs = block_secs(self.block);
624 if self.reliable {
625 if let Some(cfg) = &self.recovery {
626 recovery::sweep_orphans(self.connection.pool(), cfg, &self.key, &self.processing)
627 .await?;
628 }
629 let value: Option<Vec<u8>> = empty_on_timeout(
630 self.reader
631 .client()
632 .blmove(
633 self.key.as_str(),
634 self.processing.as_str(),
635 LMoveDirection::Right,
636 LMoveDirection::Left,
637 secs,
638 )
639 .await,
640 )?;
641 let Some(value) = value else {
642 return Ok(None);
643 };
644 let handle = match &self.recovery {
645 Some(cfg) => {
646 let member =
647 recovery::record_claim(self.connection.pool(), cfg, &value).await?;
648 Some(RecoveryHandle {
649 zset_key: cfg.zset_key.clone(),
650 member,
651 })
652 }
653 None => None,
654 };
655 Ok(Some(self.reliable_message(value, handle)))
656 } else {
657 let popped: Option<(String, Vec<u8>)> =
658 empty_on_timeout(self.reader.client().brpop(self.key.as_str(), secs).await)?;
659 Ok(popped.map(|(_, v)| self.simple_message(&v)))
660 }
661 }
662}
663
664/// One entry a pipelined list read claimed or popped: its raw value and, on a reliable list with
665/// orphan recovery, the member its claim is tracked by.
666type Claimed = (Vec<u8>, Option<RecoveryHandle>);
667
668impl ListWire {
669 /// The settle side of this subscription inside a window.
670 pub(crate) fn round_form(&self) -> ListForm {
671 ListForm::new(
672 self.key.as_str(),
673 self.processing.as_str(),
674 self.recovery.as_ref().map(|cfg| cfg.zset_key.as_str()),
675 )
676 }
677
678 /// The window's client: a connection of the pool, where no pop blocks.
679 pub(crate) fn round_client(&self) -> Client {
680 self.connection.pool().next().clone()
681 }
682
683 /// The key this subscription reads.
684 pub(crate) fn key(&self) -> &str {
685 &self.key
686 }
687
688 /// Takes up to `count` entries in one round trip, and waits for one when the list is empty.
689 ///
690 /// A reliable list claims with `LMOVE` into its processing list, `count` of them in one
691 /// pipeline, and keeps `BLMOVE` for waiting on an empty queue; a simple list pops with
692 /// `RPOP key count` and waits with `BRPOP`. Both read the right end, the end a producer's
693 /// `LPUSH` makes the oldest.
694 async fn claim(&self, count: usize, into: &mut VecDeque<Claimed>) -> Result<(), RedisError> {
695 // The read connection is the subscription's own, so the broker's shutdown does not close
696 // it: a read after the shutdown refuses here instead of delivering past it.
697 self.connection.ensure_open()?;
698 let before = into.len();
699 if self.reliable {
700 if let Some(cfg) = &self.recovery {
701 recovery::sweep_orphans(self.connection.pool(), cfg, &self.key, &self.processing)
702 .await?;
703 }
704 let claims = self.reader.client().pipeline();
705 for _ in 0..count {
706 claims
707 .lmove::<(), _, _>(
708 self.key.as_str(),
709 self.processing.as_str(),
710 LMoveDirection::Right,
711 LMoveDirection::Left,
712 )
713 .await
714 .map_err(RedisError::stream)?;
715 }
716 for reply in claims.try_all::<Value>().await {
717 // An empty answer is a pop that found the queue dry; a producer may push between
718 // two of them, so every answer is read: each entry answered is on the processing
719 // list now and has to be delivered.
720 match reply.map_err(RedisError::stream)? {
721 Value::Null => {}
722 value => into.push_back((value_bytes(value)?, None)),
723 }
724 }
725 } else {
726 let popped: Value = self
727 .reader
728 .client()
729 .rpop(self.key.as_str(), Some(count))
730 .await
731 .map_err(RedisError::stream)?;
732 if let Value::Array(values) = popped {
733 for value in values {
734 into.push_back((value_bytes(value)?, None));
735 }
736 }
737 }
738 if into.len() == before
739 && let Some(value) = self.wait_one().await?
740 {
741 // The queue was empty: the blocking pop waited for one, and the next read claims the
742 // rest of what arrived.
743 into.push_back((value, None));
744 }
745 self.track(into.range_mut(before..)).await
746 }
747
748 /// Waits for one entry on an empty list: `BLMOVE` into the processing list on a reliable list,
749 /// `BRPOP` on a simple one. `None` when the wait timed out.
750 async fn wait_one(&self) -> Result<Option<Vec<u8>>, RedisError> {
751 #[cfg(feature = "testing")]
752 self.tap.readable().await;
753 let secs = block_secs(self.block);
754 if self.reliable {
755 empty_on_timeout(
756 self.reader
757 .client()
758 .blmove(
759 self.key.as_str(),
760 self.processing.as_str(),
761 LMoveDirection::Right,
762 LMoveDirection::Left,
763 secs,
764 )
765 .await,
766 )
767 } else {
768 let popped: Option<(String, Vec<u8>)> =
769 empty_on_timeout(self.reader.client().brpop(self.key.as_str(), secs).await)?;
770 Ok(popped.map(|(_, value)| value))
771 }
772 }
773
774 /// Records every fresh claim in the recovery ZSET, in one pipeline.
775 async fn track<'a>(
776 &self,
777 claimed: impl Iterator<Item = &'a mut Claimed>,
778 ) -> Result<(), RedisError> {
779 let Some(cfg) = &self.recovery else {
780 return Ok(());
781 };
782 let tracking = self.connection.pool().next().pipeline();
783 let mut any = false;
784 for (value, handle) in claimed {
785 let (score, member) = cfg.tracked(value);
786 tracking
787 .zadd::<(), _, _>(
788 cfg.zset_key.as_str(),
789 None,
790 None,
791 false,
792 false,
793 (score, member.clone()),
794 )
795 .await
796 .map_err(RedisError::stream)?;
797 *handle = Some(RecoveryHandle {
798 zset_key: cfg.zset_key.clone(),
799 member,
800 });
801 any = true;
802 }
803 if !any {
804 return Ok(());
805 }
806 if let Some(ttl) = cfg.ttl_millis() {
807 tracking
808 .pexpire::<(), _>(cfg.zset_key.as_str(), ttl, None)
809 .await
810 .map_err(RedisError::stream)?;
811 }
812 for reply in tracking.try_all::<Value>().await {
813 reply.map_err(RedisError::stream)?;
814 }
815 Ok(())
816 }
817
818 fn message_of(&self, (value, recovery): Claimed) -> RedisListMessage {
819 if self.reliable {
820 self.reliable_message(value, recovery)
821 } else {
822 self.simple_message(&value)
823 }
824 }
825}
826
827/// The raw bytes of a popped value.
828fn value_bytes(value: Value) -> Result<Vec<u8>, RedisError> {
829 match value {
830 Value::Bytes(bytes) => Ok(bytes.to_vec()),
831 Value::String(text) => Ok(text.as_bytes().to_vec()),
832 other => Err(RedisError::Stream(
833 format!("a list read answered with {other:?} where an entry was expected").into(),
834 )),
835 }
836}
837
838/// A list subscription's reader, as a pipelined subscription holds it: the wire and the entries
839/// its last read claimed.
840pub struct ListReader {
841 wire: ListWire,
842 buffer: VecDeque<Claimed>,
843}
844
845impl Debug for ListReader {
846 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
847 f.debug_struct("ListReader")
848 .field("wire", &self.wire)
849 .field("buffered", &self.buffer.len())
850 .finish()
851 }
852}
853
854impl ListReader {
855 pub(crate) const fn new(wire: ListWire) -> Self {
856 Self {
857 wire,
858 buffer: VecDeque::new(),
859 }
860 }
861
862 /// Yields one delivery per entry, each with a slot in `window`, claiming `count` at a time,
863 /// and reports a failed flush of the window once.
864 pub(crate) fn round_stream<'a>(
865 &'a mut self,
866 window: &'a Arc<Window<ListForm>>,
867 count: usize,
868 ) -> impl Stream<Item = Result<RoundMessage<ListForm>, RedisError>> + Send + 'a {
869 unfold(self, move |reader| async move {
870 loop {
871 if let Some(failure) = window.take_failure() {
872 return Some((Err(failure), reader));
873 }
874 if let Some(claimed) = reader.buffer.pop_front() {
875 let delivery = reader.wire.message_of(claimed);
876 let round = window.open();
877 return Some((
878 Ok(RoundMessage::new(delivery, Arc::clone(window), round)),
879 reader,
880 ));
881 }
882 if let Err(err) = reader.wire.claim(count, &mut reader.buffer).await {
883 return Some((Err(err), reader));
884 }
885 window.fetched(reader.buffer.len());
886 }
887 })
888 }
889}
890
891impl ListReader {
892 /// Yields one batch per read of up to `size` entries, sharing one slot of `window`, so what
893 /// the batch handler queued commits with the settles of every entry.
894 pub(crate) fn round_batches<'a>(
895 &'a mut self,
896 window: &'a Arc<Window<ListForm>>,
897 size: NonZeroUsize,
898 ) -> impl Stream<Item = Result<Vec<RoundMessage<ListForm>>, RedisError>> + Send + 'a {
899 window.fill_at(size.get());
900 unfold(self, move |reader| async move {
901 loop {
902 if let Some(failure) = window.take_failure() {
903 return Some((Err(failure), reader));
904 }
905 if !reader.buffer.is_empty() {
906 let take = size.get().min(reader.buffer.len());
907 let members = u32::try_from(take).unwrap_or(u32::MAX);
908 let round = window.open_batch(members);
909 let batch = reader
910 .buffer
911 .drain(..take)
912 .collect::<Vec<_>>()
913 .into_iter()
914 .map(|claimed| {
915 RoundMessage::new(
916 reader.wire.message_of(claimed),
917 Arc::clone(window),
918 round.clone(),
919 )
920 })
921 .collect();
922 return Some((Ok(batch), reader));
923 }
924 if let Err(err) = reader.wire.claim(size.get(), &mut reader.buffer).await {
925 return Some((Err(err), reader));
926 }
927 window.fetched(reader.buffer.len());
928 }
929 })
930 }
931}
932
933impl ruststream::Subscriber for ListWire {
934 type Message = RedisListMessage;
935 type Error = RedisError;
936
937 fn stream(&mut self) -> impl Stream<Item = Result<Self::Message, Self::Error>> + Send + '_ {
938 unfold(&*self, |s| async move {
939 loop {
940 match s.next_entry().await {
941 Ok(Some(msg)) => return Some((Ok(msg), s)),
942 Ok(None) => {}
943 Err(err) => return Some((Err(err), s)),
944 }
945 }
946 })
947 }
948}
949
950/// Settlement handle for a reliable-mode list delivery.
951struct ListAck {
952 main_key: String,
953 processing_key: String,
954 /// The raw wire value (framed), needed verbatim to `LREM` it from the processing list.
955 value: Vec<u8>,
956 /// Set when orphan recovery is enabled: the ZSET key and the member tracking this claim, so
957 /// settling removes its recovery tracking.
958 recovery: Option<RecoveryHandle>,
959}
960
961/// The recovery-ZSET coordinates for one in-flight reliable-list claim.
962struct RecoveryHandle {
963 zset_key: String,
964 member: Vec<u8>,
965}
966
967/// A list-queue delivery. In simple mode `ack` / `nack` are unsupported; in reliable mode `ack`
968/// removes the entry from the processing list and `nack` either returns it or drops it.
969pub struct RedisListMessage {
970 payload: Bytes,
971 headers: HeaderMap,
972 ack: Option<ListAck>,
973 /// The connection the entry was read on: what settles it, and what `Ctx<keys::FredPool>`
974 /// hands the handler.
975 connection: Arc<Connection>,
976 /// The test harness's count of this delivery; empty outside the in-process mode.
977 #[cfg_attr(
978 not(feature = "testing"),
979 allow(
980 dead_code,
981 reason = "held for its release on drop, which only `testing` gives"
982 )
983 )]
984 flight: InFlight,
985}
986
987impl RedisListMessage {
988 /// The broker's connection pool, for the per-delivery context.
989 pub(crate) fn pool(&self) -> &Pool {
990 self.connection.pool()
991 }
992
993 /// Takes the harness's count of this delivery, for a window to hold until its flush.
994 #[cfg(feature = "testing")]
995 pub(crate) fn take_flight(&mut self) -> InFlight {
996 std::mem::take(&mut self.flight)
997 }
998
999 /// Takes what a window settles this delivery with: the raw entry and the member its claim is
1000 /// tracked by, on a reliable list. `None` on a simple list, which settles nothing.
1001 pub(crate) fn into_settle(self) -> Option<(Vec<u8>, Option<Vec<u8>>)> {
1002 self.ack
1003 .map(|handle| (handle.value, handle.recovery.map(|rec| rec.member)))
1004 }
1005}
1006
1007impl Debug for RedisListMessage {
1008 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1009 f.debug_struct("RedisListMessage")
1010 .field("payload_len", &self.payload.len())
1011 .field("reliable", &self.ack.is_some())
1012 .finish_non_exhaustive()
1013 }
1014}
1015
1016impl IncomingMessage for RedisListMessage {
1017 fn payload(&self) -> &[u8] {
1018 &self.payload
1019 }
1020
1021 fn headers(&self) -> &HeaderMap {
1022 &self.headers
1023 }
1024
1025 // See `RedisMessage`: the keyed worker lanes read the key from here, not from `Partitioned`.
1026 fn partition_key(&self) -> Option<&[u8]> {
1027 Partitioned::partition_key(self)
1028 }
1029
1030 async fn ack(self) -> Result<(), AckError> {
1031 let Some(handle) = self.ack else {
1032 return Err(AckError::Unsupported);
1033 };
1034 self.connection.ensure_open_to_settle()?;
1035 settle(self.connection.pool(), &handle).await
1036 }
1037
1038 async fn nack(self, requeue: bool) -> Result<(), AckError> {
1039 let Some(handle) = self.ack else {
1040 return Err(AckError::Unsupported);
1041 };
1042 self.connection.ensure_open_to_settle()?;
1043 let pool = self.connection.pool();
1044 if requeue {
1045 // Return the original entry verbatim to the main list, before removing it from
1046 // processing (a crash in between leaves a duplicate rather than a loss).
1047 lpush(pool, handle.main_key.as_str(), handle.value.clone()).await?;
1048 }
1049 settle(pool, &handle).await
1050 }
1051}
1052
1053fn ack_broker(err: fred::error::Error) -> AckError {
1054 AckError::Broker(Box::new(err))
1055}
1056
1057async fn lpush(pool: &Pool, key: &str, body: Vec<u8>) -> Result<(), AckError> {
1058 let _: i64 = pool.lpush(key, body).await.map_err(ack_broker)?;
1059 Ok(())
1060}
1061
1062/// Removes the entry from the processing list and, when recovery is enabled, drops its tracking from
1063/// the recovery ZSET.
1064async fn settle(pool: &Pool, handle: &ListAck) -> Result<(), AckError> {
1065 let _: i64 = pool
1066 .lrem(handle.processing_key.as_str(), 1, handle.value.clone())
1067 .await
1068 .map_err(ack_broker)?;
1069 if let Some(rec) = &handle.recovery {
1070 recovery::forget(pool, &rec.zset_key, &rec.member).await?;
1071 }
1072 Ok(())
1073}
1074
1075impl Partitioned for RedisListMessage {
1076 fn partition_key(&self) -> Option<&[u8]> {
1077 self.headers().get(PARTITION_KEY_HEADER)
1078 }
1079}
1080
1081/// The declaration half of the list publisher: envelope codec and key TTL, no connection.
1082///
1083/// Constructible anywhere, it pairs into a [`RedisListPublisher`] against a
1084/// [`ConnectedRedisBroker`].
1085///
1086/// # Examples
1087///
1088/// ```
1089/// # mod demo {
1090/// use std::time::Duration;
1091///
1092/// use ruststream::codec::JsonCodec;
1093/// use ruststream_fred::RedisListPublish;
1094/// use ruststream_fred::list::prelude::*;
1095/// use serde::{Deserialize, Serialize};
1096///
1097/// #[derive(Deserialize)]
1098/// struct Job {
1099/// id: u64,
1100/// }
1101///
1102/// #[derive(Serialize, Outgoing)]
1103/// #[outgoing(name = "jobs.done")]
1104/// struct Done {
1105/// id: u64,
1106/// }
1107///
1108/// #[subscriber(RedisList::new("jobs").reliable(), reply)]
1109/// async fn run_job(job: &Job) -> Done {
1110/// Done { id: job.id }
1111/// }
1112///
1113/// #[ruststream::app]
1114/// fn app() -> impl App {
1115/// RustStream::new(AppInfo::new("jobs", "0.1.0")).with_broker(
1116/// RedisBroker::standalone("redis://localhost:6379"),
1117/// |b| {
1118/// // A readable JSON frame, and a `jobs.done` nobody drains expires five minutes
1119/// // after the last push.
1120/// b.include(run_job).out_reply(
1121/// RedisListPublish::new()
1122/// .codec(JsonCodec)
1123/// .ttl(Duration::from_secs(300)),
1124/// );
1125/// },
1126/// )
1127/// }
1128/// # }
1129/// # fn main() {}
1130/// ```
1131#[derive(Clone, Default)]
1132#[must_use]
1133pub struct RedisListPublish {
1134 codec: Option<SharedEnvelope>,
1135 ttl: Option<Duration>,
1136}
1137
1138impl Debug for RedisListPublish {
1139 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1140 f.debug_struct("RedisListPublish")
1141 .field("codec", &self.codec.is_some())
1142 .field("ttl", &self.ttl)
1143 .finish()
1144 }
1145}
1146
1147impl RedisListPublish {
1148 /// A policy with the default binary framing and no key TTL. Equivalent to [`Self::default`].
1149 pub fn new() -> Self {
1150 Self::default()
1151 }
1152
1153 /// Serializes the header/payload envelope with `codec` (must match the subscriber), which makes
1154 /// the wire value readable while the data is text. Without it the default binary framing is
1155 /// used. Either way a payload that is not text is published byte for byte.
1156 pub fn codec(mut self, codec: impl Codec + 'static) -> Self {
1157 self.codec = Some(Arc::new(codec));
1158 self
1159 }
1160
1161 /// Sets a time-to-live on the list key, refreshed (`PEXPIRE`) on every publish, so an idle
1162 /// queue auto-expires. Off by default: without it the list lives until drained or deleted.
1163 ///
1164 /// This is a per-key TTL on the whole list, not per-entry: Redis lists have no per-element
1165 /// expiry, only the key can expire. Each publish pushes the entry and re-arms the key's TTL in
1166 /// one pipeline, so an actively used queue never expires and only an idle one does. A sub-
1167 /// millisecond `ttl` is clamped up to 1ms, since `PEXPIRE 0` would delete the key outright.
1168 pub const fn ttl(mut self, ttl: Duration) -> Self {
1169 self.ttl = Some(ttl);
1170 self
1171 }
1172}
1173
1174impl PublishPolicy<ConnectedRedisBroker> for RedisListPublish {
1175 type Live = RedisListPublisher;
1176
1177 fn pair(
1178 self,
1179 connected: &ConnectedRedisBroker,
1180 ) -> impl Future<Output = Result<Self::Live, PairError>> {
1181 ready(Ok(connected.list_publisher(self)))
1182 }
1183
1184 /// The list key the policy pushes onto, the expiry it re-arms there on every push, and how
1185 /// headers are framed beside the payload.
1186 #[cfg(feature = "asyncapi")]
1187 fn channel_bindings(&self, channel: &str) -> Bindings {
1188 crate::asyncapi::channel(&crate::asyncapi::Publish::list(
1189 channel,
1190 self.ttl
1191 .map(|ttl| u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX)),
1192 crate::asyncapi::Envelope::of(self.codec.as_ref()),
1193 ))
1194 }
1195}
1196
1197/// Publishes onto a list with `LPUSH`, so right-popping consumers see FIFO order: a
1198/// [`RedisListPublish`] policy paired with a connection.
1199///
1200/// Obtain it from
1201/// [`ConnectedRedisBroker::list_publisher`](crate::ConnectedRedisBroker::list_publisher), or by
1202/// pairing the policy. Publishing after the connection was shut down reports
1203/// [`RedisError::ShutDown`].
1204#[derive(Clone)]
1205pub struct RedisListPublisher {
1206 core: Arc<RedisCore>,
1207 codec: Option<SharedEnvelope>,
1208 ttl: Option<Duration>,
1209 /// What `pipeline.bind(&out)` names this publisher and its clones by.
1210 round_name: u64,
1211}
1212
1213impl Debug for RedisListPublisher {
1214 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1215 f.debug_struct("RedisListPublisher")
1216 .field("codec", &self.codec.is_some())
1217 .field("ttl", &self.ttl)
1218 .finish_non_exhaustive()
1219 }
1220}
1221
1222impl RedisListPublisher {
1223 /// What `pipeline.bind(&out)` names this publisher and its clones by.
1224 pub(crate) const fn round_name(&self) -> u64 {
1225 self.round_name
1226 }
1227
1228 pub(crate) fn new(core: Arc<RedisCore>, publish: RedisListPublish) -> Self {
1229 Self {
1230 round_name: core.rounds().publisher(),
1231 core,
1232 codec: publish.codec,
1233 ttl: publish.ttl,
1234 }
1235 }
1236}
1237
1238/// Converts a TTL to the positive millisecond count `PEXPIRE` expects, clamping a sub-millisecond
1239/// value up to 1 (a `PEXPIRE 0` deletes the key instead of expiring it).
1240fn ttl_millis(ttl: Duration) -> i64 {
1241 i64::try_from(ttl.as_millis()).unwrap_or(i64::MAX).max(1)
1242}
1243
1244impl ruststream::Publisher for RedisListPublisher {
1245 /// As on [`RedisPubSubPublisher`](crate::RedisPubSubPublisher): the entry the client is
1246 /// handed is an envelope this crate builds, so the payload is only read.
1247 type Payload = Lend;
1248
1249 type Error = RedisError;
1250 /// `LPUSH` carries the key and the value and nothing else; the key TTL is a property of the
1251 /// queue, fixed by the policy and re-armed on every publish. What a call site still says is
1252 /// the partition key, framed into the entry with the message's other headers.
1253 type Options = RedisPublishOptions;
1254
1255 async fn publish(
1256 &self,
1257 msg: ruststream::OutgoingMessage<'_>,
1258 options: Option<&Self::Options>,
1259 ) -> Result<(), Self::Error> {
1260 let (key, payload, headers) = msg.into_parts();
1261 if let Some(round) = self
1262 .core
1263 .rounds()
1264 .joined(self.round_name, joins_round(options))
1265 {
1266 let body = frame(
1267 self.codec.as_ref(),
1268 payload,
1269 &resolved_headers(headers, options),
1270 );
1271 return round
1272 .lpush(key, body, self.ttl.map(ttl_millis))
1273 .await
1274 .map_err(RedisError::publish);
1275 }
1276 push(
1277 &self.core,
1278 self.codec.as_ref(),
1279 self.ttl,
1280 key,
1281 payload,
1282 resolved_headers(headers, options),
1283 )
1284 .await
1285 }
1286}
1287
1288/// `LPUSH`es one framed entry onto `key`, re-arming the key's `ttl` when there is one: the list
1289/// publish, shared by [`RedisListPublisher`] and the broker's default publisher.
1290pub(crate) async fn push(
1291 core: &RedisCore,
1292 codec: Option<&SharedEnvelope>,
1293 ttl: Option<Duration>,
1294 key: &str,
1295 payload: &[u8],
1296 headers: HeaderMap,
1297) -> Result<(), RedisError> {
1298 let pool = core.pool()?;
1299 let body = frame(codec, payload, &headers);
1300 let Some(ttl) = ttl else {
1301 let _: i64 = pool.lpush(key, body).await.map_err(RedisError::publish)?;
1302 return Ok(());
1303 };
1304 // Push the entry and re-arm the key TTL in one pipeline, so an actively used queue keeps
1305 // resetting its expiry and only an idle one is allowed to lapse.
1306 let pipeline = pool.next().pipeline();
1307 let _: () = pipeline
1308 .lpush(key, body)
1309 .await
1310 .map_err(RedisError::publish)?;
1311 let _: () = pipeline
1312 .pexpire(key, ttl_millis(ttl), None)
1313 .await
1314 .map_err(RedisError::publish)?;
1315 let _: Vec<Value> = pipeline.all().await.map_err(RedisError::publish)?;
1316 Ok(())
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321 use super::*;
1322
1323 #[test]
1324 fn ttl_millis_converts_and_clamps() {
1325 assert_eq!(ttl_millis(Duration::from_mins(1)), 60_000);
1326 assert_eq!(ttl_millis(Duration::from_millis(1)), 1);
1327 // A sub-millisecond TTL must not become PEXPIRE 0 (which deletes the key).
1328 assert_eq!(ttl_millis(Duration::from_nanos(1)), 1);
1329 assert_eq!(ttl_millis(Duration::ZERO), 1);
1330 }
1331}