Skip to main content

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}