Skip to main content

ruststream_fred/
publisher.rs

1//! Publishes messages to Redis streams via `XADD`, with both transaction kinds on top: the
2//! borrowed [`TransactionalPublisher`] on the handle and the owned [`OwnedTransactions`] value.
3
4use std::fmt::{Debug, Formatter};
5use std::sync::{Arc, Mutex};
6
7use fred::interfaces::{StreamsInterface, TransactionInterface};
8use fred::types::Value;
9use ruststream::{
10    DefaultPublish, OutgoingMessage, OwnedTransactions, PairError, PublishPolicy, Publisher,
11    Transaction, TransactionalPublisher,
12};
13use tracing::warn;
14
15use crate::broker::{ConnectedRedisBroker, RedisCore};
16use crate::{convert::fields_for_publish, error::RedisError};
17
18/// One buffered `XADD` (stream key plus its encoded entry fields), held while a transaction is open.
19type Buffered = (String, Vec<(String, Vec<u8>)>);
20
21/// Flushes `buffered` as one `MULTI` / `EXEC` block, in publish order.
22///
23/// The single flush path of both transaction kinds: they differ only in where the buffer lives
24/// (the handle's slot for the borrowed kind, the [`RedisTransaction`] value for the owned one),
25/// never in what a commit does.
26///
27/// # Errors
28///
29/// Returns [`RedisError::ShutDown`] when the connection is gone, or [`RedisError::Publish`] when
30/// the block is rejected.
31async fn flush_block(core: &RedisCore, buffered: Vec<Buffered>) -> Result<(), RedisError> {
32    if buffered.is_empty() {
33        return Ok(());
34    }
35    let pool = core.pool()?;
36    let txn = pool.next().multi();
37    for (key, fields) in buffered {
38        // Queued client-side by `fred`; the whole block travels on one connection at `exec`.
39        let _: () = txn
40            .xadd(key, false, None::<()>, "*", fields)
41            .await
42            .map_err(RedisError::publish)?;
43    }
44    // `abort_on_error = true`: a command the server refuses to queue discards the block instead
45    // of committing a partial one.
46    let _: Value = txn.exec(true).await.map_err(RedisError::publish)?;
47    Ok(())
48}
49
50/// The declaration half of the stream publisher: pure policy, constructible anywhere.
51///
52/// `XADD` needs no options beyond the target key, which travels on each message, so the policy is
53/// a unit marker. It pairs into a [`RedisPublisher`] against a [`ConnectedRedisBroker`], which is
54/// what makes "publishing before connect" unrepresentable.
55///
56/// # Examples
57///
58/// ```no_run
59/// use ruststream::{Broker, PublishPolicy};
60/// use ruststream_fred::{RedisBroker, RedisPublish};
61///
62/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
63/// let policy = RedisPublish; // no connection in sight
64/// let connected = RedisBroker::standalone("redis://localhost:6379").connect().await?;
65/// let publisher = policy.pair(&connected).await?;
66/// # let _ = publisher;
67/// # Ok(())
68/// # }
69/// ```
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71#[must_use]
72pub struct RedisPublish;
73
74impl PublishPolicy<ConnectedRedisBroker> for RedisPublish {
75    type Live = RedisPublisher;
76
77    async fn pair(self, connected: &ConnectedRedisBroker) -> Result<Self::Live, PairError> {
78        Ok(connected.publisher())
79    }
80}
81
82impl DefaultPublish for ConnectedRedisBroker {
83    type Policy = RedisPublish;
84}
85
86/// The live stream publisher: [`RedisPublish`] paired with a connection. Cheap to clone.
87///
88/// [`Publisher::publish`] appends the message to the stream named by
89/// [`OutgoingMessage::name`](ruststream::OutgoingMessage::name) with `XADD <name> * ...`. The
90/// payload and headers are encoded as entry fields (see [`crate::RedisStream`] for the consuming
91/// side).
92///
93/// A publisher may outlive the connected broker it came from (it is a handle aliasing the
94/// connection), so every operation after
95/// [`shutdown`](ruststream::ConnectedBroker::shutdown) reports [`RedisError::ShutDown`] rather
96/// than running against a closed pool.
97///
98/// # Transactions
99///
100/// Both framework transaction kinds are available on standalone and sentinel topologies, and both
101/// commit the same way: the buffer is held client-side while the transaction is open and flushed
102/// as one `MULTI` / `EXEC` block, in publish order, so subscribers see the whole batch or none of
103/// it. They differ only in where that buffer lives.
104///
105/// * Borrowed ([`TransactionalPublisher`]): the handle carries one transaction.
106///   [`begin_transaction`](TransactionalPublisher::begin_transaction) claims it and starts
107///   buffering published messages, [`commit`](TransactionalPublisher::commit) flushes them, and
108///   [`abort`](TransactionalPublisher::abort) discards them. Clones of a handle share the same
109///   open transaction, and a second `begin_transaction` while one is open is rejected.
110/// * Owned ([`OwnedTransactions`]): every [`transaction`](OwnedTransactions::transaction) call
111///   returns a [`RedisTransaction`] owning its own buffer, so any number can be open on one
112///   handle concurrently and the handle keeps publishing directly meanwhile.
113///
114/// Two Redis properties apply to both kinds. Cluster supports neither, because a `MULTI` block
115/// cannot span hash slots, so opening a transaction there returns
116/// [`RedisError::InvalidOptions`]. And Redis has no rollback: a command that fails at *runtime*
117/// inside `EXEC` does not undo the commands before it. For a block of `XADD`s against stream keys
118/// that is practically limited to out-of-memory and wrong-type keys; a command the server refuses
119/// to *queue* discards the whole block.
120#[derive(Clone)]
121pub struct RedisPublisher {
122    core: Arc<RedisCore>,
123    txn: Arc<Mutex<Option<Vec<Buffered>>>>,
124}
125
126impl Debug for RedisPublisher {
127    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("RedisPublisher")
129            .field("core", &self.core)
130            .finish_non_exhaustive()
131    }
132}
133
134impl RedisPublisher {
135    pub(crate) fn new(core: Arc<RedisCore>) -> Self {
136        Self {
137            core,
138            txn: Arc::new(Mutex::new(None)),
139        }
140    }
141
142    /// Rejects a transaction on a topology that cannot offer one. Shared by both kinds so they
143    /// answer identically.
144    fn check_transactions_supported(&self) -> Result<(), RedisError> {
145        if self.core.transactions_supported() {
146            return Ok(());
147        }
148        Err(RedisError::InvalidOptions(
149            "transactions are only supported on standalone and sentinel topologies".to_owned(),
150        ))
151    }
152
153    /// Buffers `entry` if a transaction is open and returns `true`; otherwise leaves it for an
154    /// immediate publish.
155    fn buffer_if_in_txn(&self, entry: &Buffered) -> bool {
156        let mut guard = self.txn.lock().expect("redis publisher mutex poisoned");
157        let buffered = guard.as_mut().is_some_and(|buffer| {
158            buffer.push(entry.clone());
159            true
160        });
161        drop(guard);
162        buffered
163    }
164}
165
166impl Publisher for RedisPublisher {
167    type Error = RedisError;
168
169    async fn publish(&self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
170        let entry: Buffered = (
171            msg.name().to_owned(),
172            fields_for_publish(msg.payload(), msg.headers()),
173        );
174        if self.buffer_if_in_txn(&entry) {
175            return Ok(());
176        }
177        let pool = self.core.pool()?;
178        let (key, fields) = entry;
179        let _: String = pool
180            .xadd(key, false, None::<()>, "*", fields)
181            .await
182            .map_err(RedisError::publish)?;
183        Ok(())
184    }
185}
186
187impl TransactionalPublisher for RedisPublisher {
188    /// Starts buffering published messages on this handle.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`RedisError::InvalidOptions`] on a cluster topology, which cannot offer
193    /// multi-key transactions, or [`RedisError::TransactionBusy`] when a transaction is already
194    /// open on this handle (the open one is left untouched).
195    async fn begin_transaction(&self) -> Result<(), Self::Error> {
196        self.check_transactions_supported()?;
197        let mut guard = self.txn.lock().expect("redis publisher mutex poisoned");
198        if guard.is_some() {
199            return Err(RedisError::TransactionBusy);
200        }
201        *guard = Some(Vec::new());
202        drop(guard);
203        Ok(())
204    }
205
206    /// Flushes the buffered `XADD`s as one `MULTI` / `EXEC` block, in publish order, then clears
207    /// the transaction.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`RedisError::NoTransaction`] when no transaction is open on this handle,
212    /// [`RedisError::ShutDown`] when the connection is gone, or [`RedisError::Publish`] if the
213    /// block is rejected. On failure the transaction is already closed: the buffer is lost, and
214    /// recovery is redelivery of the inputs rather than resubmission of the buffer.
215    async fn commit(&self) -> Result<(), Self::Error> {
216        // Taken before the flush: a failed commit has still closed the transaction.
217        let buffered = self
218            .txn
219            .lock()
220            .expect("redis publisher mutex poisoned")
221            .take()
222            .ok_or(RedisError::NoTransaction)?;
223        flush_block(&self.core, buffered).await
224    }
225
226    /// Discards the buffered messages.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`RedisError::NoTransaction`] when no transaction is open on this handle.
231    async fn abort(&self) -> Result<(), Self::Error> {
232        self.txn
233            .lock()
234            .expect("redis publisher mutex poisoned")
235            .take()
236            .ok_or(RedisError::NoTransaction)
237            .map(|_| ())
238    }
239}
240
241/// Owned transactions: every [`transaction`](OwnedTransactions::transaction) call opens an
242/// independent buffer-owning [`RedisTransaction`], so any number can be open concurrently on one
243/// handle, next to (and unaffected by) the handle-level [`TransactionalPublisher`] transaction.
244impl OwnedTransactions for RedisPublisher {
245    type Transaction = RedisTransaction;
246
247    /// # Errors
248    ///
249    /// Returns [`RedisError::InvalidOptions`] on a cluster topology, which cannot offer
250    /// multi-key transactions.
251    async fn transaction(&self) -> Result<RedisTransaction, RedisError> {
252        self.check_transactions_supported()?;
253        // Opening allocates a buffer and never touches the connection; a connection torn down
254        // before the flush surfaces at commit, the visibility point, like the handle-level begin.
255        Ok(RedisTransaction {
256            core: Arc::clone(&self.core),
257            buffered: Vec::new(),
258            settled: false,
259        })
260    }
261}
262
263/// An owned Redis transaction, opened by [`transaction`](OwnedTransactions::transaction) on a
264/// [`RedisPublisher`].
265///
266/// A private `XADD` buffer, flushed on commit through the same `MULTI` / `EXEC` path as the
267/// handle-level kind (so the whole batch becomes visible atomically, in publish order) and
268/// discarded on abort.
269///
270/// What sets it apart from the handle-level [`TransactionalPublisher`] buffer is ownership, not
271/// the commit: any number of these can be open on one handle at a time, and the handle keeps
272/// publishing directly while they are. The buffers are independent, so settling one never touches
273/// another; only the flush itself takes a pooled connection.
274///
275/// # Examples
276///
277/// ```no_run
278/// use ruststream::{Broker, OutgoingMessage, OwnedTransactions, Transaction};
279/// use ruststream_fred::RedisBroker;
280///
281/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
282/// let connected = RedisBroker::standalone("redis://localhost:6379").connect().await?;
283/// let publisher = connected.publisher();
284///
285/// let mut orders = publisher.transaction().await?;
286/// let mut audit = publisher.transaction().await?; // concurrent with `orders`
287/// orders.publish(OutgoingMessage::new("orders", b"{}".as_slice())).await?;
288/// audit.publish(OutgoingMessage::new("audit", b"{}".as_slice())).await?;
289/// orders.commit().await?;
290/// audit.commit().await?;
291/// # Ok(())
292/// # }
293/// ```
294#[must_use = "a transaction does nothing until settled with commit() or abort()"]
295pub struct RedisTransaction {
296    core: Arc<RedisCore>,
297    buffered: Vec<Buffered>,
298    settled: bool,
299}
300
301impl Debug for RedisTransaction {
302    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
303        f.debug_struct("RedisTransaction")
304            .field("buffered", &self.buffered.len())
305            .field("settled", &self.settled)
306            .finish_non_exhaustive()
307    }
308}
309
310impl Drop for RedisTransaction {
311    fn drop(&mut self) {
312        // Destructors cannot run async work, so a drop can only discard the buffer; the warning
313        // marks that as an abort the caller never wrote.
314        if !self.settled {
315            warn!(
316                target: "ruststream_fred",
317                buffered = self.buffered.len(),
318                "owned transaction dropped without commit or abort; its buffered messages are \
319                 discarded"
320            );
321        }
322    }
323}
324
325impl Transaction for RedisTransaction {
326    type Error = RedisError;
327
328    /// Buffers the `XADD` locally; nothing reaches the server before [`commit`](Self::commit).
329    async fn publish(&mut self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
330        self.buffered.push((
331            msg.name().to_owned(),
332            fields_for_publish(msg.payload(), msg.headers()),
333        ));
334        Ok(())
335    }
336
337    /// Flushes the buffer as one `MULTI` / `EXEC` block, in publish order.
338    ///
339    /// # Errors
340    ///
341    /// Returns [`RedisError::ShutDown`] when the connection this transaction was opened from is
342    /// gone, or [`RedisError::Publish`] when the block is rejected. A failed commit has still
343    /// consumed the transaction and its buffer is lost; redelivery of the inputs, not
344    /// resubmission of the buffer, is the recovery path.
345    async fn commit(mut self) -> Result<(), Self::Error> {
346        // Settled before the flush: a failed commit has still consumed the transaction (the
347        // buffer is lost per the Transaction contract), so the drop warning must not fire.
348        self.settled = true;
349        flush_block(&self.core, std::mem::take(&mut self.buffered)).await
350    }
351
352    /// Discards the buffer. Nothing was sent to the server, so this cannot fail.
353    async fn abort(mut self) -> Result<(), Self::Error> {
354        self.settled = true;
355        Ok(())
356    }
357}