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}