Skip to main content

ruststream_fred/
delay.rs

1//! Durable delayed retry backed by a Redis sorted-set (ZSET) delay queue.
2//!
3//! `retry_after(delay)` (a handler returning [`HandlerOutcome::retry_after`], or a delivery
4//! `nack_after`-ed) asks the broker to redeliver a message no sooner than `delay` from now. Redis
5//! Streams have no native per-message delay, so without this the runtime falls back to its
6//! broker-agnostic deferred re-publish: the mount site binds the publisher that copy leaves
7//! through with `out_retry`, and the copy is at-most-once over the delay window (a process crash
8//! before the timer fires loses it). A registration that binds nothing drops the delay and
9//! requeues the message at once.
10//!
11//! This module adds a crash-safe alternative, **opt-in and OFF by default**: when a subscription
12//! names a ZSET key with [`RedisStream::delayed_retry`](crate::RedisStream::delayed_retry),
13//! `nack_after` becomes native (`supports_nack_after` reports `true`). A delayed message is `ZADD`ed
14//! to the named ZSET with score `fire_at = now + delay`, then the original is `XACK`ed; a sweeper
15//! folded into the subscriber's read loop moves due entries (`score <= now`) back onto the source
16//! stream with `XADD`. The entry lives in Redis across a crash, so redelivery survives a restart.
17//!
18//! Scores are wall-clock epoch milliseconds taken on the publishing process, so deployments should
19//! keep clocks reasonably synced (NTP) - the same assumption any wall-clock delay queue makes.
20//!
21//! [`HandlerOutcome::retry_after`]: ruststream::runtime::HandlerOutcome::retry_after
22
23use std::time::{Duration, SystemTime, UNIX_EPOCH};
24
25use bytes::Bytes;
26use fred::clients::Pool;
27use fred::interfaces::{KeysInterface, SortedSetsInterface, StreamsInterface};
28use ruststream::runtime::RETRY_COUNT_HEADER;
29use ruststream::{AckError, HeaderMap};
30
31use crate::convert::fields_for_publish;
32use crate::envelope::{frame, unframe};
33use crate::error::RedisError;
34use crate::loopback::Loopback;
35
36/// How many due entries one sweep pass claims and re-publishes before yielding back to the read
37/// loop. Bounds the work a single fetch does so a large backlog cannot stall fresh delivery.
38const SWEEP_BATCH: i64 = 128;
39
40/// How a subscription should handle `retry_after` / `nack_after` delays.
41///
42/// Passed to [`RedisStream::delayed_retry`](crate::RedisStream::delayed_retry). There is no default
43/// that enables it: a delay queue costs extra Redis keys, memory, and the polling sweeper, so a user
44/// opts in and names the ZSET key explicitly (the key has no sane default).
45///
46/// # Examples
47///
48/// ```
49/// # mod demo {
50/// use std::time::Duration;
51///
52/// use ruststream_fred::stream::prelude::*;
53/// use serde::Deserialize;
54///
55/// #[derive(Deserialize)]
56/// struct Order {
57///     id: u64,
58///     paid: bool,
59/// }
60///
61/// // An unpaid order waits a minute in the ZSET, where a restart does not lose it.
62/// #[subscriber(
63///     RedisStream::new("orders")
64///         .group("workers")
65///         .delayed_retry(DelayedRetry::DurableZset {
66///             key: "orders.delayed".to_owned(),
67///             ttl: Some(Duration::from_secs(3600)),
68///         })
69/// )]
70/// async fn handle(order: &Order) -> HandlerOutcome {
71///     if !order.paid {
72///         return HandlerOutcome::retry_after(Duration::from_secs(60));
73///     }
74///     println!("shipping order {}", order.id);
75///     HandlerOutcome::ack()
76/// }
77///
78/// #[ruststream::app]
79/// fn app() -> impl App {
80///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
81///         RedisBroker::standalone("redis://localhost:6379"),
82///         |b| {
83///             b.include(handle);
84///         },
85///     )
86/// }
87/// # }
88/// # fn main() {}
89/// ```
90#[derive(Debug, Clone)]
91#[non_exhaustive]
92pub enum DelayedRetry {
93    /// Schedule delayed redeliveries in the named ZSET.
94    ///
95    /// `key` is the ZSET delay-queue key (required, no default). `ttl`, when set, `PEXPIRE`s the
96    /// key on every write so an abandoned queue cleans itself up; it **must exceed the longest
97    /// scheduled `retry_after` delay**, or pending entries are dropped before they fire.
98    DurableZset {
99        /// The ZSET delay-queue key.
100        key: String,
101        /// Optional auto-cleanup TTL on the ZSET key. Must exceed the longest scheduled delay.
102        ttl: Option<Duration>,
103    },
104}
105
106/// The resolved delay-queue settings a subscriber and its messages carry. Cheap to clone.
107#[derive(Debug, Clone)]
108pub(crate) struct DelayConfig {
109    zset_key: String,
110    ttl: Option<Duration>,
111    /// The connection the queue lives on, whose clock the scores are read off in process.
112    loopback: Loopback,
113}
114
115impl DelayConfig {
116    pub(crate) fn from_retry(retry: &DelayedRetry) -> Self {
117        match retry {
118            DelayedRetry::DurableZset { key, ttl } => Self {
119                zset_key: key.clone(),
120                ttl: *ttl,
121                loopback: Loopback::default(),
122            },
123        }
124    }
125
126    /// The same queue on the connection `loopback` names.
127    pub(crate) fn on(mut self, loopback: &Loopback) -> Self {
128        self.loopback.clone_from(loopback);
129        self
130    }
131
132    /// The wall clock the scores are written and swept against, in epoch milliseconds: the
133    /// system's, or in process the in-process server's.
134    fn now_ms(&self) -> u64 {
135        #[cfg(feature = "testing")]
136        {
137            self.loopback.now_ms()
138        }
139        #[cfg(not(feature = "testing"))]
140        {
141            let _ = &self.loopback;
142            now_ms()
143        }
144    }
145}
146
147/// Current wall-clock time as epoch milliseconds (the ZSET score space).
148#[cfg_attr(
149    feature = "testing",
150    allow(dead_code, reason = "the loopback reads the clock under `testing`")
151)]
152fn now_ms() -> u64 {
153    SystemTime::now()
154        .duration_since(UNIX_EPOCH)
155        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
156}
157
158fn delay_millis(delay: Duration) -> u64 {
159    u64::try_from(delay.as_millis()).unwrap_or(u64::MAX)
160}
161
162/// Epoch-millisecond timestamps stay well under 2^53, so representing one as the f64 score a ZSET
163/// uses is lossless in practice.
164#[allow(
165    clippy::cast_precision_loss,
166    reason = "epoch-ms < 2^53 is exact in f64"
167)]
168fn as_score(ms: u64) -> f64 {
169    ms as f64
170}
171
172/// Positive millisecond count for `PEXPIRE`, clamped up to 1 (a `PEXPIRE 0` deletes the key).
173fn ttl_millis(ttl: Duration) -> i64 {
174    i64::try_from(ttl.as_millis()).unwrap_or(i64::MAX).max(1)
175}
176
177/// Packs an entry for a ZSET member: a length-prefixed delivery id (a uniqueness salt, so two
178/// byte-identical payloads do not collide into one member) followed by the lossless header/payload
179/// frame. The id is not reused on redelivery; the sweep re-`XADD`s under a fresh id.
180fn encode_member(id: &str, payload: &[u8], headers: &HeaderMap) -> Vec<u8> {
181    let body = frame(None, payload, headers);
182    let id = id.as_bytes();
183    let id_len = u32::try_from(id.len()).unwrap_or(u32::MAX);
184    let mut buf = Vec::with_capacity(4 + id.len() + body.len());
185    buf.extend_from_slice(&id_len.to_be_bytes());
186    buf.extend_from_slice(id);
187    buf.extend_from_slice(&body);
188    buf
189}
190
191/// Reverses [`encode_member`], dropping the id salt and returning the payload and headers.
192fn decode_member(member: &[u8]) -> Option<(Bytes, HeaderMap)> {
193    let id_len = u32::from_be_bytes(member.get(0..4)?.try_into().ok()?) as usize;
194    let body = member.get(4usize.checked_add(id_len)?..)?;
195    Some(unframe(None, body))
196}
197
198/// The framework retry count a re-published copy carries next: the one on the delivery, plus this
199/// round.
200fn next_retry_count(headers: &HeaderMap) -> u64 {
201    headers
202        .get_str(RETRY_COUNT_HEADER)
203        .and_then(|v| v.parse::<u64>().ok())
204        .unwrap_or(0)
205        + 1
206}
207
208fn broker_err(err: fred::error::Error) -> AckError {
209    AckError::Broker(Box::new(err))
210}
211
212impl DelayConfig {
213    /// The key of the delay queue.
214    pub(crate) fn zset_key(&self) -> &str {
215        &self.zset_key
216    }
217
218    /// The expiry re-armed on the delay queue with every `ZADD`, in milliseconds.
219    pub(crate) fn ttl_millis(&self) -> Option<i64> {
220        self.ttl.map(ttl_millis)
221    }
222
223    /// The `ZADD` a delayed redelivery owes: the score it is due at and the member that carries
224    /// it, with the retry-count header raised.
225    pub(crate) fn scheduled(
226        &self,
227        id: &str,
228        payload: &[u8],
229        headers: &HeaderMap,
230        delay: Duration,
231    ) -> (f64, Vec<u8>) {
232        let fire_at = self.now_ms().saturating_add(delay_millis(delay));
233        let mut next = headers.clone();
234        next.insert(RETRY_COUNT_HEADER, next_retry_count(headers).to_string());
235        (as_score(fire_at), encode_member(id, payload, &next))
236    }
237
238    /// `ZADD`s a delayed copy of the message (retry count incremented) at `now + delay`,
239    /// refreshing the optional key TTL. The caller `XACK`s the original afterwards, so a crash in
240    /// between leaves the scheduled copy (a duplicate) rather than losing the message.
241    pub(crate) async fn schedule(
242        &self,
243        pool: &Pool,
244        id: &str,
245        payload: &[u8],
246        headers: &HeaderMap,
247        delay: Duration,
248    ) -> Result<(), AckError> {
249        let (score, member) = self.scheduled(id, payload, headers, delay);
250        let _: i64 = pool
251            .zadd(
252                self.zset_key.as_str(),
253                None,
254                None,
255                false,
256                false,
257                (score, member),
258            )
259            .await
260            .map_err(broker_err)?;
261        if let Some(ttl) = self.ttl {
262            let _: i64 = pool
263                .pexpire(self.zset_key.as_str(), ttl_millis(ttl), None)
264                .await
265                .map_err(broker_err)?;
266        }
267        Ok(())
268    }
269
270    /// Moves entries whose `fire_at` has passed from the delay ZSET back onto `stream_key`.
271    ///
272    /// Each due member is claimed with `ZREM`: only the consumer whose `ZREM` removes it re-`XADD`s
273    /// it, so concurrent sweepers never double-publish. Bounded to [`SWEEP_BATCH`] entries per
274    /// pass.
275    pub(crate) async fn sweep_due(&self, pool: &Pool, stream_key: &str) -> Result<(), RedisError> {
276        let now = as_score(self.now_ms());
277        let due: Vec<Bytes> = pool
278            .zrangebyscore(
279                self.zset_key.as_str(),
280                0.0,
281                now,
282                false,
283                Some((0, SWEEP_BATCH)),
284            )
285            .await
286            .map_err(RedisError::stream)?;
287
288        for member in due {
289            let removed: i64 = pool
290                .zrem(self.zset_key.as_str(), member.clone())
291                .await
292                .map_err(RedisError::stream)?;
293            // Another sweeper already claimed and re-published this entry.
294            if removed != 1 {
295                continue;
296            }
297            let Some((payload, headers)) = decode_member(&member) else {
298                continue;
299            };
300            let fields = fields_for_publish(payload.to_vec(), &headers);
301            let _: String = pool
302                .xadd(stream_key, false, None::<()>, "*", fields)
303                .await
304                .map_err(RedisError::stream)?;
305        }
306        Ok(())
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn member_round_trips_payload_and_headers() {
316        let mut headers = HeaderMap::new();
317        headers.insert("content-type", "application/json");
318        headers.insert(RETRY_COUNT_HEADER, "2");
319
320        let member = encode_member("1700000000000-0", b"{}", &headers);
321        let (payload, decoded) = decode_member(&member).expect("decodes");
322        assert_eq!(payload.as_ref(), b"{}");
323        assert_eq!(decoded.content_type(), Some("application/json"));
324        assert_eq!(decoded.get_str(RETRY_COUNT_HEADER), Some("2"));
325    }
326
327    #[test]
328    fn distinct_ids_yield_distinct_members_for_equal_payloads() {
329        let headers = HeaderMap::new();
330        let a = encode_member("1-0", b"dup", &headers);
331        let b = encode_member("2-0", b"dup", &headers);
332        assert_ne!(
333            a, b,
334            "the id salt must keep equal payloads from colliding in the ZSET"
335        );
336    }
337
338    #[test]
339    fn next_retry_count_starts_at_one_and_increments() {
340        let mut headers = HeaderMap::new();
341        assert_eq!(next_retry_count(&headers), 1);
342        headers.insert(RETRY_COUNT_HEADER, "4");
343        assert_eq!(next_retry_count(&headers), 5);
344        // A malformed counter restarts from zero rather than panicking.
345        headers.insert(RETRY_COUNT_HEADER, "not-a-number");
346        assert_eq!(next_retry_count(&headers), 1);
347    }
348
349    #[test]
350    fn ttl_millis_clamps_sub_millisecond_to_one() {
351        assert_eq!(ttl_millis(Duration::from_secs(30)), 30_000);
352        assert_eq!(ttl_millis(Duration::ZERO), 1);
353    }
354}