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}