Skip to main content

ruststream_fred/
pubsub.rs

1//! Redis Pub/Sub transport: fire-and-forget fan-out with no acknowledgement.
2//!
3//! Unlike Streams, Pub/Sub has no durability, no consumer groups, and no ack: a message reaches
4//! whichever subscribers are connected at publish time, and `ack` / `nack` report
5//! [`AckError::Unsupported`]. Two delivery modes exist, explicit because they do not interoperate:
6//!
7//! * [`PubSubMode::Classic`] - `SUBSCRIBE` / `PUBLISH`, broadcast to every node; supports patterns
8//!   (`PSUBSCRIBE`). The only option on standalone and sentinel.
9//! * [`PubSubMode::Sharded`] - `SSUBSCRIBE` / `SPUBLISH` (Redis 7+), slot-local so it scales across
10//!   a cluster, but has no pattern support.
11//!
12//! Headers travel in a frame around the payload (see [`crate::envelope`]): a lossless binary frame
13//! by default, or a readable codec-serialized envelope when a codec is set with
14//! [`RedisPubSub::codec`] / [`RedisPubSubPublish::codec`].
15
16use std::fmt::{Debug, Formatter};
17use std::sync::Arc;
18
19use bytes::Bytes;
20use fred::clients::Client;
21use fred::interfaces::{ClientLike, PubsubInterface};
22use fred::types::{Message, MessageKind};
23use futures::Stream;
24use futures::stream::unfold;
25use ruststream::codec::Codec;
26use ruststream::{
27    AckError, Headers, IncomingMessage, OutgoingMessage, PairError, Partitioned, PublishPolicy,
28    Publisher, SubscriptionSource,
29};
30use tokio::sync::broadcast::{Receiver, error::RecvError};
31
32use crate::broker::{ConnectedRedisBroker, RedisCore};
33use crate::envelope::{SharedEnvelope, frame, unframe};
34use crate::{error::RedisError, message::PARTITION_KEY_HEADER};
35
36/// Pub/Sub delivery mode. Defaults to [`Classic`](Self::Classic).
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub enum PubSubMode {
39    /// `SUBSCRIBE` / `PUBLISH`: cluster-wide broadcast, pattern-capable, does not scale by slot.
40    #[default]
41    Classic,
42    /// `SSUBSCRIBE` / `SPUBLISH` (Redis 7+): slot-local sharded delivery, no patterns.
43    Sharded,
44}
45
46/// Describes one Pub/Sub subscription against a [`ConnectedRedisBroker`].
47///
48/// # Examples
49///
50/// ```
51/// use ruststream_fred::{PubSubMode, RedisPubSub};
52///
53/// let classic = RedisPubSub::new("events");
54/// let sharded = RedisPubSub::new("events").mode(PubSubMode::Sharded);
55/// let pattern = RedisPubSub::new("events.*").pattern(); // classic only
56/// # let _ = (classic, sharded, pattern);
57/// ```
58#[derive(Clone)]
59#[must_use]
60pub struct RedisPubSub {
61    channel: String,
62    mode: PubSubMode,
63    pattern: bool,
64    codec: Option<SharedEnvelope>,
65}
66
67impl Debug for RedisPubSub {
68    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("RedisPubSub")
70            .field("channel", &self.channel)
71            .field("mode", &self.mode)
72            .field("pattern", &self.pattern)
73            .field("codec", &self.codec.is_some())
74            .finish()
75    }
76}
77
78impl RedisPubSub {
79    /// A subscription on `channel` (an exact channel by default; see [`pattern`](Self::pattern)).
80    pub fn new(channel: impl Into<String>) -> Self {
81        Self {
82            channel: channel.into(),
83            mode: PubSubMode::default(),
84            pattern: false,
85            codec: None,
86        }
87    }
88
89    /// Sets the delivery mode. Defaults to [`PubSubMode::Classic`].
90    pub const fn mode(mut self, mode: PubSubMode) -> Self {
91        self.mode = mode;
92        self
93    }
94
95    /// Treats the channel as a glob pattern (`PSUBSCRIBE`). Classic mode only; combining it with
96    /// [`PubSubMode::Sharded`] is rejected at subscribe time.
97    pub const fn pattern(mut self) -> Self {
98        self.pattern = true;
99        self
100    }
101
102    /// Decodes the header/payload envelope with `codec` (must match the publisher). Without it the
103    /// default lossless binary framing is used.
104    pub fn codec(mut self, codec: impl Codec + 'static) -> Self {
105        self.codec = Some(Arc::new(codec));
106        self
107    }
108
109    /// The channel (or pattern) this subscription listens on.
110    #[must_use]
111    pub fn channel(&self) -> &str {
112        &self.channel
113    }
114
115    pub(crate) const fn delivery_mode(&self) -> PubSubMode {
116        self.mode
117    }
118
119    pub(crate) const fn is_pattern(&self) -> bool {
120        self.pattern
121    }
122
123    pub(crate) fn codec_handle(&self) -> Option<SharedEnvelope> {
124        self.codec.clone()
125    }
126
127    pub(crate) fn validate(&self) -> Result<(), RedisError> {
128        if self.pattern && matches!(self.mode, PubSubMode::Sharded) {
129            return Err(RedisError::InvalidOptions(
130                "pattern subscriptions are classic-only; sharded pub/sub has no PSUBSCRIBE"
131                    .to_owned(),
132            ));
133        }
134        Ok(())
135    }
136}
137
138impl SubscriptionSource<ConnectedRedisBroker> for RedisPubSub {
139    type Subscriber = RedisPubSubSubscriber;
140
141    fn name(&self) -> &str {
142        self.channel()
143    }
144
145    async fn subscribe(
146        self,
147        connected: &ConnectedRedisBroker,
148    ) -> Result<Self::Subscriber, RedisError> {
149        connected.subscribe_pubsub(self).await
150    }
151}
152
153#[cfg(feature = "testing")]
154impl SubscriptionSource<crate::testing::ConnectedRedisTestBroker> for RedisPubSub {
155    type Subscriber = crate::testing::RedisTestSubscriber;
156
157    fn name(&self) -> &str {
158        self.channel()
159    }
160
161    async fn subscribe(
162        self,
163        connected: &crate::testing::ConnectedRedisTestBroker,
164    ) -> Result<Self::Subscriber, RedisError> {
165        connected.subscribe(self.channel()).await
166    }
167}
168
169/// A Pub/Sub subscription backed by a dedicated `fred` client, so its message stream and channel
170/// state are isolated from other subscribers and from the publishing pool.
171pub struct RedisPubSubSubscriber {
172    client: Client,
173    rx: Receiver<Message>,
174    codec: Option<SharedEnvelope>,
175}
176
177impl Debug for RedisPubSubSubscriber {
178    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
179        f.debug_struct("RedisPubSubSubscriber")
180            .finish_non_exhaustive()
181    }
182}
183
184impl RedisPubSubSubscriber {
185    pub(crate) fn new(
186        client: Client,
187        rx: Receiver<Message>,
188        codec: Option<SharedEnvelope>,
189    ) -> Self {
190        Self { client, rx, codec }
191    }
192}
193
194impl Drop for RedisPubSubSubscriber {
195    fn drop(&mut self) {
196        // The dedicated client owns a background connection task; close it on a detached task since
197        // `drop` cannot await.
198        let client = self.client.clone();
199        tokio::spawn(async move {
200            let _ = client.quit().await;
201        });
202    }
203}
204
205fn to_message(msg: &Message, codec: Option<&SharedEnvelope>) -> RedisPubSubMessage {
206    let raw = msg.value.as_bytes().unwrap_or(&[]);
207    let (payload, headers) = unframe(codec, raw);
208    RedisPubSubMessage {
209        channel: msg.channel.to_string(),
210        // `PMessage` is the delivery kind for a `PSUBSCRIBE` match; the message's own channel is the
211        // concrete one matched, which differs from the subscription's glob pattern.
212        pattern: matches!(msg.kind, MessageKind::PMessage),
213        payload,
214        headers,
215    }
216}
217
218impl ruststream::Subscriber for RedisPubSubSubscriber {
219    type Message = RedisPubSubMessage;
220    type Error = RedisError;
221
222    /// Yields one message per Pub/Sub delivery.
223    ///
224    /// # Cancel safety
225    ///
226    /// Dropping the returned stream between items is safe. Because Pub/Sub has no buffering, any
227    /// message published while no stream is polling is lost (this is Redis Pub/Sub semantics, not a
228    /// limitation of this client).
229    fn stream(&mut self) -> impl Stream<Item = Result<Self::Message, Self::Error>> + Send + '_ {
230        let codec = self.codec.clone();
231        unfold((&mut self.rx, codec), |(rx, codec)| async move {
232            loop {
233                match rx.recv().await {
234                    Ok(msg) => {
235                        let message = to_message(&msg, codec.as_ref());
236                        return Some((Ok(message), (rx, codec)));
237                    }
238                    // The receiver fell behind the broadcast buffer; skip the gap and keep reading.
239                    Err(RecvError::Lagged(_)) => {}
240                    Err(RecvError::Closed) => return None,
241                }
242            }
243        })
244    }
245}
246
247/// A Pub/Sub delivery. `ack` / `nack` are unsupported (Pub/Sub has no acknowledgement).
248pub struct RedisPubSubMessage {
249    channel: String,
250    /// Whether this delivery arrived through a `PSUBSCRIBE` pattern match (vs an exact subscribe).
251    pattern: bool,
252    payload: Bytes,
253    headers: Headers,
254}
255
256impl Debug for RedisPubSubMessage {
257    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
258        f.debug_struct("RedisPubSubMessage")
259            .field("channel", &self.channel)
260            .field("pattern", &self.pattern)
261            .field("payload_len", &self.payload.len())
262            .finish_non_exhaustive()
263    }
264}
265
266impl RedisPubSubMessage {
267    /// The channel this message arrived on.
268    ///
269    /// For a pattern ([`RedisPubSub::pattern`]) subscription this is the concrete channel the
270    /// message was published to, which differs from the glob the subscription registered.
271    #[must_use]
272    pub fn channel(&self) -> &str {
273        &self.channel
274    }
275
276    /// Whether this delivery arrived through a `PSUBSCRIBE` pattern match rather than an exact
277    /// channel subscribe.
278    #[must_use]
279    pub fn from_pattern(&self) -> bool {
280        self.pattern
281    }
282}
283
284impl IncomingMessage for RedisPubSubMessage {
285    fn payload(&self) -> &[u8] {
286        &self.payload
287    }
288
289    fn headers(&self) -> &Headers {
290        &self.headers
291    }
292
293    async fn ack(self) -> Result<(), AckError> {
294        Err(AckError::Unsupported)
295    }
296
297    async fn nack(self, _requeue: bool) -> Result<(), AckError> {
298        Err(AckError::Unsupported)
299    }
300}
301
302impl Partitioned for RedisPubSubMessage {
303    fn partition_key(&self) -> Option<&[u8]> {
304        self.headers().get(PARTITION_KEY_HEADER)
305    }
306}
307
308/// The declaration half of the Pub/Sub publisher: delivery mode and envelope codec, no connection.
309///
310/// Constructible anywhere (in a router definition, in configuration), it pairs into a
311/// [`RedisPubSubPublisher`] against a [`ConnectedRedisBroker`]. The publish mode must match how
312/// subscribers subscribed: a sharded publish only reaches sharded subscribers.
313///
314/// # Examples
315///
316/// ```
317/// use ruststream_fred::{PubSubMode, RedisPubSubPublish};
318///
319/// let classic = RedisPubSubPublish::default();
320/// let sharded = RedisPubSubPublish::new().mode(PubSubMode::Sharded);
321/// # let _ = (classic, sharded);
322/// ```
323#[derive(Clone, Default)]
324#[must_use]
325pub struct RedisPubSubPublish {
326    mode: PubSubMode,
327    codec: Option<SharedEnvelope>,
328}
329
330impl Debug for RedisPubSubPublish {
331    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
332        f.debug_struct("RedisPubSubPublish")
333            .field("mode", &self.mode)
334            .field("codec", &self.codec.is_some())
335            .finish()
336    }
337}
338
339impl RedisPubSubPublish {
340    /// A classic-mode policy with the default binary framing. Equivalent to [`Self::default`].
341    pub fn new() -> Self {
342        Self::default()
343    }
344
345    /// Sets the publish mode. Defaults to [`PubSubMode::Classic`].
346    pub const fn mode(mut self, mode: PubSubMode) -> Self {
347        self.mode = mode;
348        self
349    }
350
351    /// Serializes the header/payload envelope with `codec` (must match the subscriber). Without it
352    /// the default lossless binary framing is used.
353    pub fn codec(mut self, codec: impl Codec + 'static) -> Self {
354        self.codec = Some(Arc::new(codec));
355        self
356    }
357}
358
359impl PublishPolicy<ConnectedRedisBroker> for RedisPubSubPublish {
360    type Live = RedisPubSubPublisher;
361
362    async fn pair(self, connected: &ConnectedRedisBroker) -> Result<Self::Live, PairError> {
363        Ok(connected.pubsub_publisher(self))
364    }
365}
366
367/// Publishes Pub/Sub messages with `PUBLISH` (classic) or `SPUBLISH` (sharded): a
368/// [`RedisPubSubPublish`] policy paired with a connection.
369///
370/// Obtain it from
371/// [`ConnectedRedisBroker::pubsub_publisher`](crate::ConnectedRedisBroker::pubsub_publisher), or
372/// by pairing the policy. Like every publisher here it may outlive the connection, so publishing
373/// after shutdown reports [`RedisError::ShutDown`].
374#[derive(Clone)]
375pub struct RedisPubSubPublisher {
376    core: Arc<RedisCore>,
377    mode: PubSubMode,
378    codec: Option<SharedEnvelope>,
379}
380
381impl Debug for RedisPubSubPublisher {
382    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
383        f.debug_struct("RedisPubSubPublisher")
384            .field("mode", &self.mode)
385            .field("codec", &self.codec.is_some())
386            .finish_non_exhaustive()
387    }
388}
389
390impl RedisPubSubPublisher {
391    pub(crate) fn new(core: Arc<RedisCore>, publish: RedisPubSubPublish) -> Self {
392        Self {
393            core,
394            mode: publish.mode,
395            codec: publish.codec,
396        }
397    }
398}
399
400impl Publisher for RedisPubSubPublisher {
401    type Error = RedisError;
402
403    async fn publish(&self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
404        let pool = self.core.pool()?;
405        let client = pool.next();
406        let channel = msg.name().to_owned();
407        let body = frame(self.codec.as_ref(), msg.payload(), msg.headers());
408        let _: i64 = match self.mode {
409            PubSubMode::Classic => client.publish(channel, body).await,
410            PubSubMode::Sharded => client.spublish(channel, body).await,
411        }
412        .map_err(RedisError::publish)?;
413        Ok(())
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::context::PubSubContext;
421    use ruststream::BuildContext;
422
423    #[test]
424    fn build_context_reads_channel_and_pattern_flag() {
425        let exact = RedisPubSubMessage {
426            channel: "events".to_owned(),
427            pattern: false,
428            payload: Bytes::from_static(b"{}"),
429            headers: Headers::new(),
430        };
431        let cx = PubSubContext::build(&exact);
432        assert_eq!(cx.channel(), "events");
433        assert!(!cx.from_pattern());
434
435        let matched = RedisPubSubMessage {
436            channel: "events.user".to_owned(),
437            pattern: true,
438            payload: Bytes::from_static(b"{}"),
439            headers: Headers::new(),
440        };
441        assert!(PubSubContext::build(&matched).from_pattern());
442    }
443
444    #[test]
445    fn pattern_with_sharded_is_rejected() {
446        let err = RedisPubSub::new("e.*")
447            .mode(PubSubMode::Sharded)
448            .pattern()
449            .validate()
450            .unwrap_err();
451        assert!(matches!(err, RedisError::InvalidOptions(msg) if msg.contains("classic-only")));
452    }
453
454    #[test]
455    fn classic_pattern_validates() {
456        RedisPubSub::new("e.*").pattern().validate().expect("ok");
457    }
458}