Skip to main content

ruststream_fred/
message.rs

1//! Delivered-message wrapper that implements [`IncomingMessage`].
2
3use std::fmt::{Debug, Formatter};
4use std::sync::Arc;
5use std::time::Duration;
6
7use bytes::Bytes;
8use fred::clients::Pool;
9use fred::interfaces::StreamsInterface;
10use ruststream::{AckError, HeaderMap, IncomingMessage, Partitioned, Positioned};
11
12use crate::convert::fields_for_publish;
13use crate::delay::DelayConfig;
14use crate::loopback::InFlight;
15use crate::seek::{EntryId, RedisGroupPosition, RedisGroupSeeker};
16use crate::stream::RequeueMode;
17
18/// The well-known header key for per-message routing / partitioning.
19///
20/// The partition key controls key-based fan-out when the runtime is configured with
21/// `workers(N, by_key)`. The value is opaque bytes; the runtime hashes it to assign a dispatch
22/// lane. Redis has no native partition concept, so the key travels as this header value on all
23/// three transports.
24///
25/// A publish sets it with the [`partition_key`](crate::RedisPublishSteps::partition_key) step,
26/// which resolves into this header. Writing the header at the call site still works, and is the
27/// spelling that carries across brokers.
28pub const PARTITION_KEY_HEADER: &str = "redis-partition-key";
29
30/// Header carrying the native Redis Streams delivery count, so a handler can branch on how many
31/// attempts a message has already had.
32///
33/// Written by the two read modes that claim: a [`reclaim`](crate::RedisStream::reclaim) delivery
34/// reports the count including itself, a [`claiming`](crate::RedisStream::claiming) delivery the
35/// attempts made before it (zero on a fresh entry). The
36/// [`redelivery_count`](IncomingMessage::redelivery_count) the runtime reads normalises the two,
37/// counting this delivery on both.
38pub const DELIVERY_COUNT_HEADER: &str = "redis-delivery-count";
39
40/// Header carrying how long (milliseconds) the delivery had been pending before this read. Zero on
41/// an entry a [`claiming`](crate::RedisStream::claiming) subscription read off the tail.
42pub const IDLE_MS_HEADER: &str = "redis-idle-ms";
43
44/// Everything a [`RedisMessage`] needs to settle itself against the stream it came from.
45struct AckHandle {
46    pool: Pool,
47    key: String,
48    group: String,
49    id: String,
50}
51
52/// A Redis Streams delivery, read from a consumer group via `XREADGROUP` or `XAUTOCLAIM`.
53///
54/// Settlement follows the republish-retry model: `ack` is `XACK`; a retry re-appends a copy of the
55/// entry to the same stream and then acks the original (at-least-once, so a duplicate is possible
56/// if the process crashes between the two); a drop acks the original.
57///
58/// On a [`claiming`](crate::RedisStream::claiming) subscription the retry is the read mode's own:
59/// the entry stays in the pending entries list, and the subscription's next read claims it back
60/// with the server's delivery count one higher, which is the count this delivery reports.
61pub struct RedisMessage {
62    payload: Bytes,
63    headers: HeaderMap,
64    ack: Option<AckHandle>,
65    /// The parsed form of the entry id, kept beside the wire form so
66    /// [`Positioned::position`] cannot fail. Both are derived from the same server-issued id.
67    entry: EntryId,
68    /// Set when the subscription opted into a durable ZSET delay queue; makes `nack_after` native.
69    delay: Option<DelayConfig>,
70    /// The server's own delivery count, counting this delivery, on the read modes that report one.
71    delivered: Option<u64>,
72    /// The subscription's reposition handle, minted once when it opened. Shared rather than
73    /// rebuilt, so carrying it costs one reference-count bump per delivery.
74    seeker: Arc<RedisGroupSeeker>,
75    /// What `nack(requeue = true)` does here, which the subscription's read mode decides.
76    requeue: RequeueMode,
77    /// The test harness's count of this delivery; empty outside the in-process mode.
78    #[cfg_attr(
79        not(feature = "testing"),
80        allow(
81            dead_code,
82            reason = "held for its release on drop, which only `testing` gives"
83        )
84    )]
85    flight: InFlight,
86}
87
88impl Debug for RedisMessage {
89    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
90        let mut s = f.debug_struct("RedisMessage");
91        s.field("payload_len", &self.payload.len());
92        if let Some(ack) = &self.ack {
93            s.field("key", &ack.key).field("id", &ack.id);
94        }
95        s.finish_non_exhaustive()
96    }
97}
98
99impl RedisMessage {
100    #[allow(
101        clippy::too_many_arguments,
102        reason = "internal constructor mirroring the descriptor"
103    )]
104    pub(crate) fn new(
105        pool: Pool,
106        key: String,
107        group: String,
108        id: String,
109        entry: EntryId,
110        payload: Bytes,
111        headers: HeaderMap,
112        delay: Option<DelayConfig>,
113        delivered: Option<u64>,
114        seeker: Arc<RedisGroupSeeker>,
115        requeue: RequeueMode,
116        flight: InFlight,
117    ) -> Self {
118        Self {
119            payload,
120            headers,
121            ack: Some(AckHandle {
122                pool,
123                key,
124                group,
125                id,
126            }),
127            entry,
128            delay,
129            delivered,
130            seeker,
131            requeue,
132            flight,
133        }
134    }
135
136    /// The stream entry ID (for example `1700000000000-0`) this message was read at.
137    #[must_use]
138    pub fn id(&self) -> Option<&str> {
139        self.ack.as_ref().map(|a| a.id.as_str())
140    }
141
142    /// The parsed entry id this message was read at.
143    #[must_use]
144    pub const fn entry_id(&self) -> EntryId {
145        self.entry
146    }
147
148    /// The consumer group this delivery was read through, or `None` once the message has settled.
149    #[must_use]
150    pub fn group(&self) -> Option<&str> {
151        self.ack.as_ref().map(|a| a.group.as_str())
152    }
153
154    /// The subscription's reposition handle, for the per-delivery context to carry.
155    pub(crate) fn seeker(&self) -> &RedisGroupSeeker {
156        &self.seeker
157    }
158
159    /// Takes the harness's count of this delivery, for a window to hold until its flush.
160    #[cfg(feature = "testing")]
161    pub(crate) fn take_flight(&mut self) -> InFlight {
162        std::mem::take(&mut self.flight)
163    }
164
165    /// Takes what a window settles this delivery with: its entry id, and the body and headers a
166    /// retry republishes or schedules.
167    pub(crate) fn into_settle(mut self) -> (String, Bytes, HeaderMap) {
168        let handle = self.ack.take().expect("RedisMessage settled twice");
169        (
170            handle.id,
171            std::mem::take(&mut self.payload),
172            std::mem::take(&mut self.headers),
173        )
174    }
175}
176
177impl Partitioned for RedisMessage {
178    fn partition_key(&self) -> Option<&[u8]> {
179        self.headers().get(PARTITION_KEY_HEADER)
180    }
181}
182
183/// The position of a delivery is the group cursor that redelivers it.
184///
185/// The cursor is exclusive (a group resumes *after* the id it holds), so the pinned position is
186/// the id immediately below this entry's - seeking to it delivers this message again, followed by
187/// the entries after it. Repositioning is group-wide; see
188/// [`RedisGroupSeeker`](crate::RedisGroupSeeker).
189impl Positioned for RedisMessage {
190    type Position = RedisGroupPosition;
191
192    fn position(&self) -> RedisGroupPosition {
193        RedisGroupPosition::after(self.entry.previous())
194    }
195}
196
197impl IncomingMessage for RedisMessage {
198    fn payload(&self) -> &[u8] {
199        &self.payload
200    }
201
202    fn headers(&self) -> &HeaderMap {
203        &self.headers
204    }
205
206    // The runtime's keyed worker lanes read the key from here, not from `Partitioned`, so the
207    // capability has to be wired through or `workers(n, by_key)` silently rotates every delivery.
208    fn partition_key(&self) -> Option<&[u8]> {
209        Partitioned::partition_key(self)
210    }
211
212    async fn ack(mut self) -> Result<(), AckError> {
213        let handle = self.ack.take().expect("RedisMessage settled twice");
214        self.seeker.ensure_open_to_settle()?;
215        xack(&handle).await
216    }
217
218    /// How many times the server has delivered this entry, counting this delivery.
219    ///
220    /// Answered on the two read modes that claim, from the count Redis keeps in the pending
221    /// entries list: [`claiming`](crate::RedisStream::claiming) is told the count as of the
222    /// previous delivery, so this one is added to it, and [`reclaim`](crate::RedisStream::reclaim)
223    /// reads `XPENDING` after the claim, where this delivery is already counted. The fresh tail
224    /// claims nothing and has no count of its own, so a cap on it is the runtime's header instead.
225    fn redelivery_count(&self) -> Option<u64> {
226        self.delivered
227    }
228
229    async fn nack(mut self, requeue: bool) -> Result<(), AckError> {
230        let handle = self.ack.take().expect("RedisMessage settled twice");
231        self.seeker.ensure_open_to_settle()?;
232        if requeue && let RequeueMode::LeavePending { .. } = self.requeue {
233            // The claiming mode retries through the pending entries list: no copy is appended and
234            // the original is not acked, so the subscription's next read claims it back once it
235            // has been idle `min_idle`, with the server's delivery count one higher.
236            drop(handle);
237            return Ok(());
238        }
239        if requeue {
240            republish(&handle, &self.payload, &self.headers).await?;
241        }
242        xack(&handle).await
243    }
244
245    /// Whether a delay reaches Redis rather than the runtime's deferred copy.
246    ///
247    /// True on a subscription that named a ZSET delay queue with
248    /// [`RedisStream::delayed_retry`](crate::RedisStream::delayed_retry), and on a
249    /// [`claiming`](crate::RedisStream::claiming) subscription, whose pending entries list is a
250    /// delayed redelivery of Redis's own. Elsewhere the runtime publishes the copy, which the
251    /// mount site can customise with `out_retry`.
252    fn supports_nack_after(&self) -> bool {
253        self.delay.is_some() || matches!(self.requeue, RequeueMode::LeavePending { .. })
254    }
255
256    /// Holds the message back for `delay` and redelivers it.
257    ///
258    /// A ZSET delay queue serves the delay exactly: the delayed copy is `ZADD`ed and the original
259    /// `XACK`ed, and the subscriber's sweeper re-`XADD`s it once due, so the retry survives a
260    /// crash. Without one, a claiming subscription leaves the entry pending and claims it back on
261    /// its own `min_idle`, which is the granularity Redis offers there: a shorter delay waits
262    /// `min_idle`, a longer one is honoured by the read that finds the entry still idle.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`AckError::Unsupported`] when the subscription has neither, or
267    /// [`AckError::Broker`] when the `ZADD` or `XACK` fails.
268    async fn nack_after(mut self, delay: Duration) -> Result<(), AckError> {
269        let handle = self.ack.take().expect("RedisMessage settled twice");
270        self.seeker.ensure_open_to_settle()?;
271        let Some(cfg) = self.delay.as_ref() else {
272            if let RequeueMode::LeavePending { .. } = self.requeue {
273                drop(handle);
274                return Ok(());
275            }
276            return Err(AckError::Unsupported);
277        };
278        // ZADD the delayed copy before XACK-ing the original, so a crash in between leaves a
279        // duplicate (the scheduled copy plus the still-pending original) rather than a loss.
280        cfg.schedule(
281            &handle.pool,
282            &handle.id,
283            &self.payload,
284            &self.headers,
285            delay,
286        )
287        .await?;
288        xack(&handle).await
289    }
290}
291
292fn broker_err(err: fred::error::Error) -> AckError {
293    AckError::Broker(Box::new(err))
294}
295
296/// Re-appends a copy of the message to the tail of its stream (the at-least-once retry). Runs before
297/// the caller's `XACK` so a crash leaves a duplicate rather than a loss.
298async fn republish(
299    handle: &AckHandle,
300    payload: &[u8],
301    headers: &HeaderMap,
302) -> Result<(), AckError> {
303    let fields = fields_for_publish(payload.to_vec(), headers);
304    let _: String = handle
305        .pool
306        .xadd(handle.key.as_str(), false, None::<()>, "*", fields)
307        .await
308        .map_err(broker_err)?;
309    Ok(())
310}
311
312async fn xack(handle: &AckHandle) -> Result<(), AckError> {
313    let _: i64 = handle
314        .pool
315        .xack(
316            handle.key.as_str(),
317            handle.group.as_str(),
318            handle.id.as_str(),
319        )
320        .await
321        .map_err(broker_err)?;
322    Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327    use std::sync::atomic::AtomicU64;
328
329    use super::*;
330    use crate::connection::Connection;
331    use crate::context::keys::{ConsumerGroup, EntryId as EntryIdKey, Position, SeekHandle};
332    use crate::context::{StreamBatchContext, StreamContext};
333    use fred::clients::Pool;
334    use fred::types::config::Config;
335    use ruststream::{BuildBatchContext, BuildContext, Field};
336
337    /// An unconnected pool (just client structs); `Pool::new` opens no sockets.
338    fn offline_pool() -> Pool {
339        Pool::new(Config::default(), None, None, None, 1).expect("offline pool")
340    }
341
342    fn delivery(id: &str) -> RedisMessage {
343        let pool = offline_pool();
344        let seeker = Arc::new(RedisGroupSeeker::new(
345            Connection::new(pool.clone()),
346            "orders",
347            "workers",
348            Arc::new(AtomicU64::new(0)),
349        ));
350        RedisMessage::new(
351            pool,
352            "orders".to_owned(),
353            "workers".to_owned(),
354            id.to_owned(),
355            id.parse().expect("valid entry id"),
356            Bytes::from_static(b"{}"),
357            HeaderMap::new(),
358            None,
359            None,
360            seeker,
361            RequeueMode::Republish,
362            InFlight::default(),
363        )
364    }
365
366    #[test]
367    fn build_context_reads_the_native_fields() {
368        let cx = StreamContext::build(&delivery("1700000000000-0"));
369        assert_eq!(EntryIdKey.get(&cx), EntryId::new(1_700_000_000_000, 0));
370        assert_eq!(ConsumerGroup.get(&cx), "workers");
371        assert_eq!(SeekHandle.get(&cx).key(), "orders");
372    }
373
374    // The seek key is what a handler repositions through, so the context has to hand back the
375    // subscription's own handle rather than one rebuilt off the delivery.
376    #[test]
377    fn build_context_carries_the_position_that_redelivers() {
378        let cx = StreamContext::build(&delivery("1700000000000-4"));
379        assert_eq!(
380            Position.get(&cx),
381            RedisGroupPosition::after(EntryId::new(1_700_000_000_000, 3))
382        );
383    }
384
385    // A batch spans many deliveries, so its context carries only what the subscription shares: the
386    // group and its cursor handle, never one delivery's entry id or position.
387    #[test]
388    fn build_batch_context_carries_only_subscription_scoped_fields() {
389        let cx = StreamBatchContext::build(&delivery("1700000000000-0"));
390        assert_eq!(ConsumerGroup.get(&cx), "workers");
391        assert_eq!(SeekHandle.get(&cx).group(), "workers");
392    }
393
394    // The group cursor is exclusive, so the position that redelivers an entry sits one id below
395    // it: pinning `<ms>-0` has to borrow from the millisecond half.
396    #[test]
397    fn position_pins_the_delivery_for_redelivery() {
398        assert_eq!(
399            delivery("1700000000000-4").position(),
400            RedisGroupPosition::after(EntryId::new(1_700_000_000_000, 3))
401        );
402        assert_eq!(
403            delivery("1700000000000-0").position(),
404            RedisGroupPosition::after(EntryId::new(1_699_999_999_999, u64::MAX))
405        );
406    }
407}