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/// tracing::info!(order.id, "shipping");
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}