Skip to main content

ruststream_fred/
list.rs

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