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::future::{Future, ready};
6use std::sync::{Arc, Mutex};
7
8use bytes::BytesMut;
9use fred::interfaces::{StreamsInterface, TransactionInterface};
10use fred::types::Value;
11#[cfg(feature = "asyncapi")]
12use ruststream::asyncapi::Bindings;
13use ruststream::{
14    DefaultPublish, HeaderMap, OutgoingMessage, OwnedTransactions, PairError, PublishPolicy,
15    Publisher, Take, Transaction, TransactionalPublisher,
16};
17use tracing::warn;
18
19use crate::broker::{ConnectedRedisBroker, RedisCore};
20use crate::envelope::frame;
21use crate::list::push;
22use crate::partition::{RedisPublishOptions, resolved_headers};
23use crate::pubsub::PubSubMode;
24use crate::pubsub::send;
25use crate::route::Route;
26use crate::{convert::fields_for_publish, error::RedisError};
27
28/// One buffered `XADD` (stream key plus its encoded entry fields), held while a transaction is open.
29type Buffered = (String, Vec<(String, Vec<u8>)>);
30
31/// Flushes `buffered` as one `MULTI` / `EXEC` block, in publish order.
32///
33/// The single flush path of both transaction kinds: they differ only in where the buffer lives
34/// (the handle's slot for the borrowed kind, the [`RedisTransaction`] value for the owned one),
35/// never in what a commit does.
36///
37/// # Errors
38///
39/// Returns [`RedisError::ShutDown`] when the connection is gone, or [`RedisError::Publish`] when
40/// the block is rejected.
41async fn flush_block(core: &RedisCore, buffered: Vec<Buffered>) -> Result<(), RedisError> {
42    if buffered.is_empty() {
43        return Ok(());
44    }
45    let pool = core.pool()?;
46    let txn = pool.next().multi();
47    for (key, fields) in buffered {
48        // Queued client-side by `fred`; the whole block travels on one connection at `exec`.
49        let _: () = txn
50            .xadd(key, false, None::<()>, "*", fields)
51            .await
52            .map_err(RedisError::publish)?;
53    }
54    // `abort_on_error = true`: a command the server refuses to queue discards the block instead
55    // of committing a partial one.
56    let _: Value = txn.exec(true).await.map_err(RedisError::publish)?;
57    Ok(())
58}
59
60/// The declaration half of the stream publisher: pure policy, constructible anywhere.
61///
62/// `XADD` needs no options beyond the target key, which travels on each message, so the policy is
63/// a unit marker. It pairs into a [`RedisPublisher`] against a [`ConnectedRedisBroker`], which is
64/// what makes "publishing before connect" unrepresentable.
65///
66/// # Examples
67///
68/// ```
69/// # mod demo {
70/// use ruststream_fred::stream::prelude::*;
71/// use serde::{Deserialize, Serialize};
72///
73/// #[derive(Deserialize)]
74/// struct Order {
75///     id: u64,
76/// }
77///
78/// #[derive(Serialize, Outgoing)]
79/// #[outgoing(name = "orders.accepted")]
80/// struct Accepted {
81///     id: u64,
82/// }
83///
84/// #[subscriber(RedisStream::new("orders").group("workers"), reply)]
85/// async fn accept(order: &Order) -> Accepted {
86///     Accepted { id: order.id }
87/// }
88///
89/// #[ruststream::app]
90/// fn app() -> impl App {
91///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
92///         RedisBroker::standalone("redis://localhost:6379"),
93///         |b| {
94///             // `Publish` is this policy under the stream prelude's name. No connection exists
95///             // yet; the runtime pairs it with the connected broker at startup.
96///             b.include(accept).out_reply(Publish);
97///         },
98///     )
99/// }
100/// # }
101/// # fn main() {}
102/// ```
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
104#[must_use]
105pub struct RedisPublish;
106
107impl PublishPolicy<ConnectedRedisBroker> for RedisPublish {
108    type Live = RedisPublisher;
109
110    fn pair(
111        self,
112        connected: &ConnectedRedisBroker,
113    ) -> impl Future<Output = Result<Self::Live, PairError>> {
114        ready(Ok(connected.publisher()))
115    }
116
117    /// An `XADD` carries its headers as entry fields and the policy has no settings of its own, so
118    /// what the document learns from here is which Redis structure the channel is and which stream
119    /// key the entries are appended to.
120    #[cfg(feature = "asyncapi")]
121    fn channel_bindings(&self, channel: &str) -> Bindings {
122        crate::asyncapi::channel(&crate::asyncapi::Publish::stream(channel))
123    }
124}
125
126/// The stream publisher's policy for a mount that publishes in transactions: it pairs into the same
127/// [`RedisPublisher`] as [`RedisPublish`], and only where the topology can run one.
128///
129/// Redis Cluster cannot run a `MULTI` / `EXEC` block across hash slots, so on a cluster this
130/// policy refuses to pair and the service fails to start, naming the registration that asked for
131/// it. Without the refusal a `.transactional()` batch reply would fail to open its transaction on
132/// every batch and redeliver it without end.
133///
134/// # Examples
135///
136/// ```
137/// # mod demo {
138/// use ruststream_fred::stream::prelude::*;
139/// use serde::{Deserialize, Serialize};
140///
141/// #[derive(Deserialize, Serialize, Outgoing)]
142/// #[outgoing(name = "orders.priced")]
143/// struct Order {
144///     id: u64,
145/// }
146///
147/// #[subscriber(RedisStream::new("orders").group("workers"), reply)]
148/// async fn price(orders: &[Order]) -> Result<Vec<Order>, HandlerOutcome> {
149///     Ok(orders.iter().map(|order| Order { id: order.id }).collect())
150/// }
151///
152/// #[ruststream::app]
153/// fn app() -> impl App {
154///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
155///         RedisBroker::standalone("redis://localhost:6379"),
156///         |b| {
157///             // `TransactionalPublish` is this policy under the stream prelude's name: one
158///             // batch's replies land together, as one MULTI / EXEC block.
159///             b.include(price.batch(nonzero!(32)))
160///                 .out_reply(TransactionalPublish)
161///                 .transactional();
162///         },
163///     )
164/// }
165/// # }
166/// # fn main() {}
167/// ```
168#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
169#[must_use]
170pub struct RedisTransactionalPublish;
171
172impl PublishPolicy<ConnectedRedisBroker> for RedisTransactionalPublish {
173    type Live = RedisPublisher;
174
175    /// # Errors
176    ///
177    /// Returns a [`PairError`] carrying [`RedisError::InvalidOptions`] on a cluster topology.
178    fn pair(
179        self,
180        connected: &ConnectedRedisBroker,
181    ) -> impl Future<Output = Result<Self::Live, PairError>> {
182        let publisher = connected.publisher();
183        ready(
184            publisher
185                .check_transactions_supported()
186                .map(|()| publisher)
187                .map_err(PairError::new),
188        )
189    }
190
191    #[cfg(feature = "asyncapi")]
192    fn channel_bindings(&self, channel: &str) -> Bindings {
193        RedisPublish.channel_bindings(channel)
194    }
195}
196
197/// The broker's default policy, what a registration publishes through when its mount site names
198/// none: a reply, a retry copy, a dead-letter move.
199///
200/// It writes each name the way this service reads it. A name a [`RedisList`] subscription reads,
201/// or dead-letters to, is `LPUSH`ed in that subscription's framing; a name a [`RedisPubSub`]
202/// subscription reads or dead-letters to is published on in its mode and framing; any other name,
203/// a stream key included, is `XADD`ed. So a retry copy and a dead-letter move leave through the
204/// publish of the form the delivery came from, with nothing named at the mount site.
205///
206/// The route is recorded when a subscription opens, so a name this service does not subscribe to
207/// is a stream. A pattern subscription records nothing: its mount site names both the destination
208/// and the policy of its copies.
209///
210/// # Examples
211///
212/// ```
213/// # mod demo {
214/// use ruststream_fred::RedisList;
215/// use ruststream_fred::stream::prelude::*;
216/// use serde::{Deserialize, Serialize};
217///
218/// #[derive(Deserialize)]
219/// struct Order {
220///     id: u64,
221/// }
222///
223/// #[derive(Deserialize, Serialize, Outgoing)]
224/// #[outgoing(name = "jobs")]
225/// struct Job {
226///     order: u64,
227/// }
228///
229/// #[subscriber(RedisStream::new("orders").group("planners"), reply)]
230/// async fn plan(order: &Order) -> Job {
231///     Job { order: order.id }
232/// }
233///
234/// #[subscriber(RedisList::new("jobs").reliable())]
235/// async fn work(job: &Job) -> HandlerOutcome {
236///     tracing::info!(job.order, "job");
237///     HandlerOutcome::ack()
238/// }
239///
240/// #[ruststream::app]
241/// fn app() -> impl App {
242///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
243///         RedisBroker::standalone("redis://localhost:6379"),
244///         |b| {
245///             // The reply names no policy, so it leaves through the default one: `jobs` is
246///             // read as a list here, so the reply is an `LPUSH`.
247///             b.include(plan);
248///             b.include(work);
249///         },
250///     )
251/// }
252/// # }
253/// # fn main() {}
254/// ```
255///
256/// [`RedisList`]: crate::RedisList
257/// [`RedisPubSub`]: crate::RedisPubSub
258#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
259#[must_use]
260pub struct RedisDefaultPublish;
261
262impl PublishPolicy<ConnectedRedisBroker> for RedisDefaultPublish {
263    type Live = RedisDefaultPublisher;
264
265    fn pair(
266        self,
267        connected: &ConnectedRedisBroker,
268    ) -> impl Future<Output = Result<Self::Live, PairError>> {
269        ready(Ok(connected.default_publisher()))
270    }
271
272    /// Described as the stream publish, the family of a name no subscription of the service reads;
273    /// the document is built before any subscription opens, so a name that turns out to be read as
274    /// a list or a channel is described as a stream.
275    #[cfg(feature = "asyncapi")]
276    fn channel_bindings(&self, channel: &str) -> Bindings {
277        crate::asyncapi::channel(&crate::asyncapi::Publish::stream(channel))
278    }
279}
280
281impl DefaultPublish for ConnectedRedisBroker {
282    type Policy = RedisDefaultPublish;
283}
284
285/// The live form of [`RedisDefaultPublish`]: writes each name the way this connection's
286/// subscriptions read it. Cheap to clone.
287///
288/// Obtain it from [`ConnectedRedisBroker::default_publisher`] or by pairing the policy. Like every
289/// publisher here it may outlive the connection, so publishing after shutdown reports
290/// [`RedisError::ShutDown`].
291#[derive(Clone)]
292pub struct RedisDefaultPublisher {
293    core: Arc<RedisCore>,
294    /// What `pipeline.bind(&out)` names this publisher and its clones by.
295    round_name: u64,
296}
297
298impl Debug for RedisDefaultPublisher {
299    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
300        f.debug_struct("RedisDefaultPublisher")
301            .field("core", &self.core)
302            .finish_non_exhaustive()
303    }
304}
305
306impl RedisDefaultPublisher {
307    /// What `pipeline.bind(&out)` names this publisher and its clones by.
308    pub(crate) const fn round_name(&self) -> u64 {
309        self.round_name
310    }
311
312    pub(crate) fn new(core: Arc<RedisCore>) -> Self {
313        Self {
314            round_name: core.rounds().publisher(),
315            core,
316        }
317    }
318}
319
320impl Publisher for RedisDefaultPublisher {
321    /// As on [`RedisPublisher`]: a name nothing reads is a stream, whose `XADD` keeps the body.
322    type Payload = Take;
323
324    type Error = RedisError;
325    /// The options every publisher of this crate takes, so a partition key resolves the same
326    /// whichever family the name is written in.
327    type Options = RedisPublishOptions;
328
329    async fn publish(
330        &self,
331        msg: OutgoingMessage<'_, BytesMut>,
332        options: Option<&Self::Options>,
333    ) -> Result<(), Self::Error> {
334        let (key, payload, headers) = msg.into_parts();
335        let headers = resolved_headers(headers, options);
336        if let Some(round) = self
337            .core
338            .rounds()
339            .joined(self.round_name, joins_round(options))
340        {
341            let joined = match self.core.routes().route(key) {
342                Route::Stream => {
343                    round
344                        .xadd(key, fields_for_publish(Vec::from(payload), &headers))
345                        .await
346                }
347                Route::List { envelope } => {
348                    round
349                        .lpush(key, frame(envelope.as_ref(), &payload, &headers), None)
350                        .await
351                }
352                Route::Channel { mode, envelope } => {
353                    round
354                        .publish(
355                            key,
356                            frame(envelope.as_ref(), &payload, &headers),
357                            mode == PubSubMode::Sharded,
358                        )
359                        .await
360                }
361            };
362            return joined.map_err(RedisError::publish);
363        }
364        match self.core.routes().route(key) {
365            Route::Stream => append(&self.core, key, Vec::from(payload), &headers).await,
366            Route::List { envelope } => {
367                push(&self.core, envelope.as_ref(), None, key, &payload, headers).await
368            }
369            Route::Channel { mode, envelope } => {
370                send(&self.core, mode, envelope.as_ref(), key, &payload, headers).await
371            }
372        }
373    }
374}
375
376/// Whether the call site asked this message to join the round of the delivery being handled.
377pub(crate) fn joins_round(options: Option<&RedisPublishOptions>) -> bool {
378    options.is_some_and(|options| options.join_round)
379}
380
381/// `XADD`s one entry onto the stream `key`: the stream publish, shared by [`RedisPublisher`] and
382/// [`RedisDefaultPublisher`].
383async fn append(
384    core: &RedisCore,
385    key: &str,
386    payload: Vec<u8>,
387    headers: &HeaderMap,
388) -> Result<(), RedisError> {
389    let pool = core.pool()?;
390    let _: String = pool
391        .xadd(
392            key,
393            false,
394            None::<()>,
395            "*",
396            fields_for_publish(payload, headers),
397        )
398        .await
399        .map_err(RedisError::publish)?;
400    Ok(())
401}
402
403/// The live stream publisher: [`RedisPublish`] paired with a connection. Cheap to clone.
404///
405/// [`Publisher::publish`] appends the message to the stream named by
406/// [`OutgoingMessage::name`](ruststream::OutgoingMessage::name) with `XADD <name> * ...`. The
407/// payload and headers are encoded as entry fields (see [`crate::RedisStream`] for the consuming
408/// side).
409///
410/// A publisher may outlive the connected broker it came from (it is a handle aliasing the
411/// connection), so every operation after
412/// [`shutdown`](ruststream::ConnectedBroker::shutdown) reports [`RedisError::ShutDown`] rather
413/// than running against a closed pool.
414///
415/// # Transactions
416///
417/// Both framework transaction kinds are available on standalone and sentinel topologies, and both
418/// commit the same way: the buffer is held client-side while the transaction is open and flushed
419/// as one `MULTI` / `EXEC` block, in publish order, so subscribers see the whole batch or none of
420/// it. They differ only in where that buffer lives.
421///
422/// * Borrowed ([`TransactionalPublisher`]): the handle carries one transaction.
423///   [`begin_transaction`](TransactionalPublisher::begin_transaction) claims it and starts
424///   buffering published messages, [`commit`](TransactionalPublisher::commit) flushes them, and
425///   [`abort`](TransactionalPublisher::abort) discards them. Clones of a handle share the same
426///   open transaction, and a second `begin_transaction` while one is open is rejected.
427/// * Owned ([`OwnedTransactions`]): every [`transaction`](OwnedTransactions::transaction) call
428///   returns a [`RedisTransaction`] owning its own buffer, so any number can be open on one
429///   handle concurrently and the handle keeps publishing directly meanwhile.
430///
431/// Two Redis properties apply to both kinds. Cluster supports neither, because a `MULTI` block
432/// cannot span hash slots, so opening a transaction there returns
433/// [`RedisError::InvalidOptions`]. And Redis has no rollback: a command that fails at *runtime*
434/// inside `EXEC` does not undo the commands before it. For a block of `XADD`s against stream keys
435/// that is practically limited to out-of-memory and wrong-type keys; a command the server refuses
436/// to *queue* discards the whole block.
437///
438/// # Examples
439///
440/// ```
441/// # mod demo {
442/// use ruststream::OutgoingMessage;
443/// use ruststream_fred::stream::prelude::*;
444/// use serde::{Deserialize, Serialize};
445///
446/// #[derive(Deserialize, Serialize, Outgoing)]
447/// struct Order {
448///     id: u64,
449/// }
450///
451/// #[subscriber("orders", reply("processed"))]
452/// async fn process(orders: &[Order]) -> Result<Vec<Order>, HandlerOutcome> {
453///     Ok(orders.iter().map(|order| Order { id: order.id }).collect())
454/// }
455///
456/// #[ruststream::app]
457/// fn app() -> impl App {
458///     let broker = RedisBroker::standalone("redis://localhost:6379").default_group("workers");
459///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
460///         // The borrowed kind: one batch's replies, buffered and committed as one MULTI / EXEC.
461///         b.include(process.batch(nonzero!(32)))
462///             .out_reply(TransactionalPublish)
463///             .transactional();
464///
465///         // The owned kind, in the hook where a service makes its first publishes.
466///         b.after_startup(TransactionalPublish, async move |publisher| {
467///             let mut seed = publisher.transaction().await?;
468///             seed.publish(
469///                 OutgoingMessage::new("processed", br#"{"id":0}"#.as_slice()),
470///                 None,
471///             )
472///             .await?;
473///             seed.commit().await
474///         });
475///     })
476/// }
477/// # }
478/// # fn main() {}
479/// ```
480#[derive(Clone)]
481pub struct RedisPublisher {
482    core: Arc<RedisCore>,
483    txn: Arc<Mutex<Option<Vec<Buffered>>>>,
484    /// What `pipeline.bind(&out)` names this publisher and its clones by.
485    round_name: u64,
486}
487
488impl Debug for RedisPublisher {
489    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
490        f.debug_struct("RedisPublisher")
491            .field("core", &self.core)
492            .finish_non_exhaustive()
493    }
494}
495
496impl RedisPublisher {
497    /// What `pipeline.bind(&out)` names this publisher and its clones by.
498    pub(crate) const fn round_name(&self) -> u64 {
499        self.round_name
500    }
501
502    pub(crate) fn new(core: Arc<RedisCore>) -> Self {
503        Self {
504            round_name: core.rounds().publisher(),
505            core,
506            txn: Arc::new(Mutex::new(None)),
507        }
508    }
509
510    /// Rejects a transaction on a topology that cannot offer one. Shared by both kinds so they
511    /// answer identically.
512    fn check_transactions_supported(&self) -> Result<(), RedisError> {
513        if self.core.transactions_supported() {
514            return Ok(());
515        }
516        Err(RedisError::InvalidOptions(
517            "transactions are only supported on standalone and sentinel topologies".to_owned(),
518        ))
519    }
520
521    /// Buffers `entry` if a transaction is open and returns `true`; otherwise leaves it for an
522    /// immediate publish.
523    fn buffer_if_in_txn(&self, entry: &Buffered) -> bool {
524        let mut guard = self.txn.lock().expect("redis publisher mutex poisoned");
525        let buffered = guard.as_mut().is_some_and(|buffer| {
526            buffer.push(entry.clone());
527            true
528        });
529        drop(guard);
530        buffered
531    }
532}
533
534impl Publisher for RedisPublisher {
535    /// `XADD` hands the body to the client as the value of a field, and the client keeps it, so
536    /// the buffer the framework wrote becomes that value.
537    type Payload = Take;
538
539    type Error = RedisError;
540    /// `XADD` takes the entry id and the trim threshold per command, and this publisher fixes
541    /// both (`*` and no trim); the stream key is the message's name, not a setting. What is left
542    /// to a call site is the partition key, which leaves as an entry field like any other header.
543    type Options = RedisPublishOptions;
544
545    async fn publish(
546        &self,
547        msg: OutgoingMessage<'_, BytesMut>,
548        options: Option<&Self::Options>,
549    ) -> Result<(), Self::Error> {
550        if let Some(round) = self
551            .core
552            .rounds()
553            .joined(self.round_name, joins_round(options))
554        {
555            let (key, payload, headers) = msg.into_parts();
556            let fields =
557                fields_for_publish(Vec::from(payload), &resolved_headers(headers, options));
558            return round.xadd(key, fields).await.map_err(RedisError::publish);
559        }
560        let (key, payload, headers) = msg.into_parts();
561        let entry: Buffered = (
562            key.to_owned(),
563            fields_for_publish(Vec::from(payload), &resolved_headers(headers, options)),
564        );
565        if self.buffer_if_in_txn(&entry) {
566            return Ok(());
567        }
568        let pool = self.core.pool()?;
569        let (key, fields) = entry;
570        let _: String = pool
571            .xadd(key, false, None::<()>, "*", fields)
572            .await
573            .map_err(RedisError::publish)?;
574        Ok(())
575    }
576}
577
578impl TransactionalPublisher for RedisPublisher {
579    /// Starts buffering published messages on this handle.
580    ///
581    /// # Errors
582    ///
583    /// Returns [`RedisError::InvalidOptions`] on a cluster topology, which cannot offer
584    /// multi-key transactions, or [`RedisError::TransactionBusy`] when a transaction is already
585    /// open on this handle (the open one is left untouched).
586    fn begin_transaction(&self) -> impl Future<Output = Result<(), Self::Error>> {
587        if let Err(err) = self.check_transactions_supported() {
588            return ready(Err(err));
589        }
590        {
591            let mut guard = self.txn.lock().expect("redis publisher mutex poisoned");
592            if guard.is_some() {
593                return ready(Err(RedisError::TransactionBusy));
594            }
595            *guard = Some(Vec::new());
596        }
597        ready(Ok(()))
598    }
599
600    /// Flushes the buffered `XADD`s as one `MULTI` / `EXEC` block, in publish order, then clears
601    /// the transaction.
602    ///
603    /// # Errors
604    ///
605    /// Returns [`RedisError::NoTransaction`] when no transaction is open on this handle,
606    /// [`RedisError::ShutDown`] when the connection is gone, or [`RedisError::Publish`] if the
607    /// block is rejected. On failure the transaction is already closed: the buffer is lost, and
608    /// recovery is redelivery of the inputs rather than resubmission of the buffer.
609    async fn commit(&self) -> Result<(), Self::Error> {
610        // Taken before the flush: a failed commit has still closed the transaction.
611        let buffered = self
612            .txn
613            .lock()
614            .expect("redis publisher mutex poisoned")
615            .take()
616            .ok_or(RedisError::NoTransaction)?;
617        flush_block(&self.core, buffered).await
618    }
619
620    /// Discards the buffered messages.
621    ///
622    /// # Errors
623    ///
624    /// Returns [`RedisError::NoTransaction`] when no transaction is open on this handle.
625    fn abort(&self) -> impl Future<Output = Result<(), Self::Error>> {
626        ready(
627            self.txn
628                .lock()
629                .expect("redis publisher mutex poisoned")
630                .take()
631                .ok_or(RedisError::NoTransaction)
632                .map(|_| ()),
633        )
634    }
635}
636
637/// Owned transactions: every [`transaction`](OwnedTransactions::transaction) call opens an
638/// independent buffer-owning [`RedisTransaction`], so any number can be open concurrently on one
639/// handle, next to (and unaffected by) the handle-level [`TransactionalPublisher`] transaction.
640impl OwnedTransactions for RedisPublisher {
641    type Transaction = RedisTransaction;
642
643    /// # Errors
644    ///
645    /// Returns [`RedisError::InvalidOptions`] on a cluster topology, which cannot offer
646    /// multi-key transactions.
647    fn transaction(&self) -> impl Future<Output = Result<RedisTransaction, RedisError>> {
648        if let Err(err) = self.check_transactions_supported() {
649            return ready(Err(err));
650        }
651        // Opening allocates a buffer and never touches the connection; a connection torn down
652        // before the flush surfaces at commit, the visibility point, like the handle-level begin.
653        ready(Ok(RedisTransaction {
654            core: Arc::clone(&self.core),
655            buffered: Vec::new(),
656            settled: false,
657        }))
658    }
659}
660
661/// An owned Redis transaction, opened by [`transaction`](OwnedTransactions::transaction) on a
662/// [`RedisPublisher`].
663///
664/// A private `XADD` buffer, flushed on commit through the same `MULTI` / `EXEC` path as the
665/// handle-level kind (so the whole batch becomes visible atomically, in publish order) and
666/// discarded on abort.
667///
668/// What sets it apart from the handle-level [`TransactionalPublisher`] buffer is ownership, not
669/// the commit: any number of these can be open on one handle at a time, and the handle keeps
670/// publishing directly while they are. The buffers are independent, so settling one never touches
671/// another; only the flush itself takes a pooled connection.
672///
673/// # Examples
674///
675/// ```
676/// # mod demo {
677/// use ruststream::OutgoingMessage;
678/// use ruststream_fred::stream::prelude::*;
679/// # use serde::Deserialize;
680/// # #[derive(Deserialize)]
681/// # struct Order {
682/// #     id: u64,
683/// # }
684/// #
685/// # #[subscriber(RedisStream::new("orders").group("workers"))]
686/// # async fn handle(order: &Order) -> HandlerOutcome {
687/// #     tracing::info!(order.id, "order");
688/// #     HandlerOutcome::ack()
689/// # }
690///
691/// #[ruststream::app]
692/// fn app() -> impl App {
693///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
694///         RedisBroker::standalone("redis://localhost:6379"),
695///         |b| {
696///             b.include(handle);
697///             // Two buffers open at once on one handle; each commits as its own MULTI / EXEC.
698///             b.after_startup(TransactionalPublish, async move |publisher| {
699///                 let mut orders = publisher.transaction().await?;
700///                 let mut audit = publisher.transaction().await?;
701///                 let seed = OutgoingMessage::new("orders", br#"{"id":0}"#.as_slice());
702///                 orders.publish(seed, None).await?;
703///                 let note = OutgoingMessage::new("audit", br#"{"seeded":1}"#.as_slice());
704///                 audit.publish(note, None).await?;
705///                 orders.commit().await?;
706///                 audit.commit().await
707///             });
708///         },
709///     )
710/// }
711/// # }
712/// # fn main() {}
713/// ```
714#[must_use = "a transaction does nothing until settled with commit() or abort()"]
715pub struct RedisTransaction {
716    core: Arc<RedisCore>,
717    buffered: Vec<Buffered>,
718    settled: bool,
719}
720
721impl Debug for RedisTransaction {
722    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
723        f.debug_struct("RedisTransaction")
724            .field("buffered", &self.buffered.len())
725            .field("settled", &self.settled)
726            .finish_non_exhaustive()
727    }
728}
729
730impl Drop for RedisTransaction {
731    fn drop(&mut self) {
732        // Destructors cannot run async work, so a drop can only discard the buffer; the warning
733        // marks that as an abort the caller never wrote.
734        if !self.settled {
735            warn!(
736                target: "ruststream_fred",
737                buffered = self.buffered.len(),
738                "owned transaction dropped without commit or abort; its buffered messages are \
739                 discarded"
740            );
741        }
742    }
743}
744
745impl Transaction for RedisTransaction {
746    /// The same as [`RedisPublisher`]'s: a buffered `XADD` hands the client the same value.
747    type Payload = Take;
748
749    type Error = RedisError;
750    /// The same as [`RedisPublisher`]'s: a buffered `XADD` is the same command, queued, so a
751    /// staged message takes a partition key the way a direct one does.
752    type Options = RedisPublishOptions;
753
754    /// Buffers the `XADD` locally; nothing reaches the server before [`commit`](Self::commit).
755    fn publish(
756        &mut self,
757        msg: OutgoingMessage<'_, BytesMut>,
758        options: Option<&Self::Options>,
759    ) -> impl Future<Output = Result<(), Self::Error>> {
760        let (key, payload, headers) = msg.into_parts();
761        self.buffered.push((
762            key.to_owned(),
763            fields_for_publish(Vec::from(payload), &resolved_headers(headers, options)),
764        ));
765        ready(Ok(()))
766    }
767
768    /// Flushes the buffer as one `MULTI` / `EXEC` block, in publish order.
769    ///
770    /// # Errors
771    ///
772    /// Returns [`RedisError::ShutDown`] when the connection this transaction was opened from is
773    /// gone, or [`RedisError::Publish`] when the block is rejected. A failed commit has still
774    /// consumed the transaction and its buffer is lost; redelivery of the inputs, not
775    /// resubmission of the buffer, is the recovery path.
776    async fn commit(mut self) -> Result<(), Self::Error> {
777        // Settled before the flush: a failed commit has still consumed the transaction (the
778        // buffer is lost per the Transaction contract), so the drop warning must not fire.
779        self.settled = true;
780        flush_block(&self.core, std::mem::take(&mut self.buffered)).await
781    }
782
783    /// Discards the buffer. Nothing was sent to the server, so this cannot fail.
784    fn abort(mut self) -> impl Future<Output = Result<(), Self::Error>> {
785        self.settled = true;
786        ready(Ok(()))
787    }
788}