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 broker's default policy, what a registration publishes through when its mount site names
127/// none: a reply, a retry copy, a dead-letter move.
128///
129/// It writes each name the way this service reads it. A name a [`RedisList`] subscription reads,
130/// or dead-letters to, is `LPUSH`ed in that subscription's framing; a name a [`RedisPubSub`]
131/// subscription reads or dead-letters to is published on in its mode and framing; any other name,
132/// a stream key included, is `XADD`ed. So a retry copy and a dead-letter move leave through the
133/// publish of the form the delivery came from, with nothing named at the mount site.
134///
135/// The route is recorded when a subscription opens, so a name this service does not subscribe to
136/// is a stream. A pattern subscription records nothing: its mount site names both the destination
137/// and the policy of its copies.
138///
139/// # Examples
140///
141/// ```
142/// # mod demo {
143/// use ruststream_fred::RedisList;
144/// use ruststream_fred::stream::prelude::*;
145/// use serde::{Deserialize, Serialize};
146///
147/// #[derive(Deserialize)]
148/// struct Order {
149///     id: u64,
150/// }
151///
152/// #[derive(Deserialize, Serialize, Outgoing)]
153/// #[outgoing(name = "jobs")]
154/// struct Job {
155///     order: u64,
156/// }
157///
158/// #[subscriber(RedisStream::new("orders").group("planners"), reply)]
159/// async fn plan(order: &Order) -> Job {
160///     Job { order: order.id }
161/// }
162///
163/// #[subscriber(RedisList::new("jobs").reliable())]
164/// async fn work(job: &Job) -> HandlerOutcome {
165///     println!("job for order {}", job.order);
166///     HandlerOutcome::ack()
167/// }
168///
169/// #[ruststream::app]
170/// fn app() -> impl App {
171///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
172///         RedisBroker::standalone("redis://localhost:6379"),
173///         |b| {
174///             // The reply names no policy, so it leaves through the default one: `jobs` is
175///             // read as a list here, so the reply is an `LPUSH`.
176///             b.include(plan);
177///             b.include(work);
178///         },
179///     )
180/// }
181/// # }
182/// # fn main() {}
183/// ```
184///
185/// [`RedisList`]: crate::RedisList
186/// [`RedisPubSub`]: crate::RedisPubSub
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
188#[must_use]
189pub struct RedisDefaultPublish;
190
191impl PublishPolicy<ConnectedRedisBroker> for RedisDefaultPublish {
192    type Live = RedisDefaultPublisher;
193
194    fn pair(
195        self,
196        connected: &ConnectedRedisBroker,
197    ) -> impl Future<Output = Result<Self::Live, PairError>> {
198        ready(Ok(connected.default_publisher()))
199    }
200
201    /// Described as the stream publish, the family of a name no subscription of the service reads;
202    /// the document is built before any subscription opens, so a name that turns out to be read as
203    /// a list or a channel is described as a stream.
204    #[cfg(feature = "asyncapi")]
205    fn channel_bindings(&self, channel: &str) -> Bindings {
206        crate::asyncapi::channel(&crate::asyncapi::Publish::stream(channel))
207    }
208}
209
210impl DefaultPublish for ConnectedRedisBroker {
211    type Policy = RedisDefaultPublish;
212}
213
214/// The live form of [`RedisDefaultPublish`]: writes each name the way this connection's
215/// subscriptions read it. Cheap to clone.
216///
217/// Obtain it from [`ConnectedRedisBroker::default_publisher`] or by pairing the policy. Like every
218/// publisher here it may outlive the connection, so publishing after shutdown reports
219/// [`RedisError::ShutDown`].
220#[derive(Clone)]
221pub struct RedisDefaultPublisher {
222    core: Arc<RedisCore>,
223    /// What `pipeline.bind(&out)` names this publisher and its clones by.
224    round_name: u64,
225}
226
227impl Debug for RedisDefaultPublisher {
228    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
229        f.debug_struct("RedisDefaultPublisher")
230            .field("core", &self.core)
231            .finish_non_exhaustive()
232    }
233}
234
235impl RedisDefaultPublisher {
236    /// What `pipeline.bind(&out)` names this publisher and its clones by.
237    pub(crate) const fn round_name(&self) -> u64 {
238        self.round_name
239    }
240
241    pub(crate) fn new(core: Arc<RedisCore>) -> Self {
242        Self {
243            round_name: core.rounds().publisher(),
244            core,
245        }
246    }
247}
248
249impl Publisher for RedisDefaultPublisher {
250    /// As on [`RedisPublisher`]: a name nothing reads is a stream, whose `XADD` keeps the body.
251    type Payload = Take;
252
253    type Error = RedisError;
254    /// The options every publisher of this crate takes, so a partition key resolves the same
255    /// whichever family the name is written in.
256    type Options = RedisPublishOptions;
257
258    async fn publish(
259        &self,
260        msg: OutgoingMessage<'_, BytesMut>,
261        options: Option<&Self::Options>,
262    ) -> Result<(), Self::Error> {
263        let (key, payload, headers) = msg.into_parts();
264        let headers = resolved_headers(headers, options);
265        if let Some(round) = self
266            .core
267            .rounds()
268            .joined(self.round_name, joins_round(options))
269        {
270            let joined = match self.core.routes().route(key) {
271                Route::Stream => {
272                    round
273                        .xadd(key, fields_for_publish(Vec::from(payload), &headers))
274                        .await
275                }
276                Route::List { envelope } => {
277                    round
278                        .lpush(key, frame(envelope.as_ref(), &payload, &headers), None)
279                        .await
280                }
281                Route::Channel { mode, envelope } => {
282                    round
283                        .publish(
284                            key,
285                            frame(envelope.as_ref(), &payload, &headers),
286                            mode == PubSubMode::Sharded,
287                        )
288                        .await
289                }
290            };
291            return joined.map_err(RedisError::publish);
292        }
293        match self.core.routes().route(key) {
294            Route::Stream => append(&self.core, key, Vec::from(payload), &headers).await,
295            Route::List { envelope } => {
296                push(&self.core, envelope.as_ref(), None, key, &payload, headers).await
297            }
298            Route::Channel { mode, envelope } => {
299                send(&self.core, mode, envelope.as_ref(), key, &payload, headers).await
300            }
301        }
302    }
303}
304
305/// Whether the call site asked this message to join the round of the delivery being handled.
306pub(crate) fn joins_round(options: Option<&RedisPublishOptions>) -> bool {
307    options.is_some_and(|options| options.join_round)
308}
309
310/// `XADD`s one entry onto the stream `key`: the stream publish, shared by [`RedisPublisher`] and
311/// [`RedisDefaultPublisher`].
312async fn append(
313    core: &RedisCore,
314    key: &str,
315    payload: Vec<u8>,
316    headers: &HeaderMap,
317) -> Result<(), RedisError> {
318    let pool = core.pool()?;
319    let _: String = pool
320        .xadd(
321            key,
322            false,
323            None::<()>,
324            "*",
325            fields_for_publish(payload, headers),
326        )
327        .await
328        .map_err(RedisError::publish)?;
329    Ok(())
330}
331
332/// The live stream publisher: [`RedisPublish`] paired with a connection. Cheap to clone.
333///
334/// [`Publisher::publish`] appends the message to the stream named by
335/// [`OutgoingMessage::name`](ruststream::OutgoingMessage::name) with `XADD <name> * ...`. The
336/// payload and headers are encoded as entry fields (see [`crate::RedisStream`] for the consuming
337/// side).
338///
339/// A publisher may outlive the connected broker it came from (it is a handle aliasing the
340/// connection), so every operation after
341/// [`shutdown`](ruststream::ConnectedBroker::shutdown) reports [`RedisError::ShutDown`] rather
342/// than running against a closed pool.
343///
344/// # Transactions
345///
346/// Both framework transaction kinds are available on standalone and sentinel topologies, and both
347/// commit the same way: the buffer is held client-side while the transaction is open and flushed
348/// as one `MULTI` / `EXEC` block, in publish order, so subscribers see the whole batch or none of
349/// it. They differ only in where that buffer lives.
350///
351/// * Borrowed ([`TransactionalPublisher`]): the handle carries one transaction.
352///   [`begin_transaction`](TransactionalPublisher::begin_transaction) claims it and starts
353///   buffering published messages, [`commit`](TransactionalPublisher::commit) flushes them, and
354///   [`abort`](TransactionalPublisher::abort) discards them. Clones of a handle share the same
355///   open transaction, and a second `begin_transaction` while one is open is rejected.
356/// * Owned ([`OwnedTransactions`]): every [`transaction`](OwnedTransactions::transaction) call
357///   returns a [`RedisTransaction`] owning its own buffer, so any number can be open on one
358///   handle concurrently and the handle keeps publishing directly meanwhile.
359///
360/// Two Redis properties apply to both kinds. Cluster supports neither, because a `MULTI` block
361/// cannot span hash slots, so opening a transaction there returns
362/// [`RedisError::InvalidOptions`]. And Redis has no rollback: a command that fails at *runtime*
363/// inside `EXEC` does not undo the commands before it. For a block of `XADD`s against stream keys
364/// that is practically limited to out-of-memory and wrong-type keys; a command the server refuses
365/// to *queue* discards the whole block.
366#[derive(Clone)]
367pub struct RedisPublisher {
368    core: Arc<RedisCore>,
369    txn: Arc<Mutex<Option<Vec<Buffered>>>>,
370    /// What `pipeline.bind(&out)` names this publisher and its clones by.
371    round_name: u64,
372}
373
374impl Debug for RedisPublisher {
375    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
376        f.debug_struct("RedisPublisher")
377            .field("core", &self.core)
378            .finish_non_exhaustive()
379    }
380}
381
382impl RedisPublisher {
383    /// What `pipeline.bind(&out)` names this publisher and its clones by.
384    pub(crate) const fn round_name(&self) -> u64 {
385        self.round_name
386    }
387
388    pub(crate) fn new(core: Arc<RedisCore>) -> Self {
389        Self {
390            round_name: core.rounds().publisher(),
391            core,
392            txn: Arc::new(Mutex::new(None)),
393        }
394    }
395
396    /// Rejects a transaction on a topology that cannot offer one. Shared by both kinds so they
397    /// answer identically.
398    fn check_transactions_supported(&self) -> Result<(), RedisError> {
399        if self.core.transactions_supported() {
400            return Ok(());
401        }
402        Err(RedisError::InvalidOptions(
403            "transactions are only supported on standalone and sentinel topologies".to_owned(),
404        ))
405    }
406
407    /// Buffers `entry` if a transaction is open and returns `true`; otherwise leaves it for an
408    /// immediate publish.
409    fn buffer_if_in_txn(&self, entry: &Buffered) -> bool {
410        let mut guard = self.txn.lock().expect("redis publisher mutex poisoned");
411        let buffered = guard.as_mut().is_some_and(|buffer| {
412            buffer.push(entry.clone());
413            true
414        });
415        drop(guard);
416        buffered
417    }
418}
419
420impl Publisher for RedisPublisher {
421    /// `XADD` hands the body to the client as the value of a field, and the client keeps it, so
422    /// the buffer the framework wrote becomes that value.
423    type Payload = Take;
424
425    type Error = RedisError;
426    /// `XADD` takes the entry id and the trim threshold per command, and this publisher fixes
427    /// both (`*` and no trim); the stream key is the message's name, not a setting. What is left
428    /// to a call site is the partition key, which leaves as an entry field like any other header.
429    type Options = RedisPublishOptions;
430
431    async fn publish(
432        &self,
433        msg: OutgoingMessage<'_, BytesMut>,
434        options: Option<&Self::Options>,
435    ) -> Result<(), Self::Error> {
436        if let Some(round) = self
437            .core
438            .rounds()
439            .joined(self.round_name, joins_round(options))
440        {
441            let (key, payload, headers) = msg.into_parts();
442            let fields =
443                fields_for_publish(Vec::from(payload), &resolved_headers(headers, options));
444            return round.xadd(key, fields).await.map_err(RedisError::publish);
445        }
446        let (key, payload, headers) = msg.into_parts();
447        let entry: Buffered = (
448            key.to_owned(),
449            fields_for_publish(Vec::from(payload), &resolved_headers(headers, options)),
450        );
451        if self.buffer_if_in_txn(&entry) {
452            return Ok(());
453        }
454        let pool = self.core.pool()?;
455        let (key, fields) = entry;
456        let _: String = pool
457            .xadd(key, false, None::<()>, "*", fields)
458            .await
459            .map_err(RedisError::publish)?;
460        Ok(())
461    }
462}
463
464impl TransactionalPublisher for RedisPublisher {
465    /// Starts buffering published messages on this handle.
466    ///
467    /// # Errors
468    ///
469    /// Returns [`RedisError::InvalidOptions`] on a cluster topology, which cannot offer
470    /// multi-key transactions, or [`RedisError::TransactionBusy`] when a transaction is already
471    /// open on this handle (the open one is left untouched).
472    fn begin_transaction(&self) -> impl Future<Output = Result<(), Self::Error>> {
473        if let Err(err) = self.check_transactions_supported() {
474            return ready(Err(err));
475        }
476        {
477            let mut guard = self.txn.lock().expect("redis publisher mutex poisoned");
478            if guard.is_some() {
479                return ready(Err(RedisError::TransactionBusy));
480            }
481            *guard = Some(Vec::new());
482        }
483        ready(Ok(()))
484    }
485
486    /// Flushes the buffered `XADD`s as one `MULTI` / `EXEC` block, in publish order, then clears
487    /// the transaction.
488    ///
489    /// # Errors
490    ///
491    /// Returns [`RedisError::NoTransaction`] when no transaction is open on this handle,
492    /// [`RedisError::ShutDown`] when the connection is gone, or [`RedisError::Publish`] if the
493    /// block is rejected. On failure the transaction is already closed: the buffer is lost, and
494    /// recovery is redelivery of the inputs rather than resubmission of the buffer.
495    async fn commit(&self) -> Result<(), Self::Error> {
496        // Taken before the flush: a failed commit has still closed the transaction.
497        let buffered = self
498            .txn
499            .lock()
500            .expect("redis publisher mutex poisoned")
501            .take()
502            .ok_or(RedisError::NoTransaction)?;
503        flush_block(&self.core, buffered).await
504    }
505
506    /// Discards the buffered messages.
507    ///
508    /// # Errors
509    ///
510    /// Returns [`RedisError::NoTransaction`] when no transaction is open on this handle.
511    fn abort(&self) -> impl Future<Output = Result<(), Self::Error>> {
512        ready(
513            self.txn
514                .lock()
515                .expect("redis publisher mutex poisoned")
516                .take()
517                .ok_or(RedisError::NoTransaction)
518                .map(|_| ()),
519        )
520    }
521}
522
523/// Owned transactions: every [`transaction`](OwnedTransactions::transaction) call opens an
524/// independent buffer-owning [`RedisTransaction`], so any number can be open concurrently on one
525/// handle, next to (and unaffected by) the handle-level [`TransactionalPublisher`] transaction.
526impl OwnedTransactions for RedisPublisher {
527    type Transaction = RedisTransaction;
528
529    /// # Errors
530    ///
531    /// Returns [`RedisError::InvalidOptions`] on a cluster topology, which cannot offer
532    /// multi-key transactions.
533    fn transaction(&self) -> impl Future<Output = Result<RedisTransaction, RedisError>> {
534        if let Err(err) = self.check_transactions_supported() {
535            return ready(Err(err));
536        }
537        // Opening allocates a buffer and never touches the connection; a connection torn down
538        // before the flush surfaces at commit, the visibility point, like the handle-level begin.
539        ready(Ok(RedisTransaction {
540            core: Arc::clone(&self.core),
541            buffered: Vec::new(),
542            settled: false,
543        }))
544    }
545}
546
547/// An owned Redis transaction, opened by [`transaction`](OwnedTransactions::transaction) on a
548/// [`RedisPublisher`].
549///
550/// A private `XADD` buffer, flushed on commit through the same `MULTI` / `EXEC` path as the
551/// handle-level kind (so the whole batch becomes visible atomically, in publish order) and
552/// discarded on abort.
553///
554/// What sets it apart from the handle-level [`TransactionalPublisher`] buffer is ownership, not
555/// the commit: any number of these can be open on one handle at a time, and the handle keeps
556/// publishing directly while they are. The buffers are independent, so settling one never touches
557/// another; only the flush itself takes a pooled connection.
558///
559/// # Examples
560///
561/// ```
562/// # mod demo {
563/// use ruststream::OutgoingMessage;
564/// use ruststream_fred::stream::prelude::*;
565/// # use serde::Deserialize;
566/// # #[derive(Deserialize)]
567/// # struct Order {
568/// #     id: u64,
569/// # }
570/// #
571/// # #[subscriber(RedisStream::new("orders").group("workers"))]
572/// # async fn handle(order: &Order) -> HandlerOutcome {
573/// #     println!("order {}", order.id);
574/// #     HandlerOutcome::ack()
575/// # }
576///
577/// #[ruststream::app]
578/// fn app() -> impl App {
579///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
580///         RedisBroker::standalone("redis://localhost:6379"),
581///         |b| {
582///             b.include(handle);
583///             // Two buffers open at once on one handle; each commits as its own MULTI / EXEC.
584///             b.after_startup(TransactionalPublish, async move |publisher| {
585///                 let mut orders = publisher.transaction().await?;
586///                 let mut audit = publisher.transaction().await?;
587///                 let seed = OutgoingMessage::new("orders", br#"{"id":0}"#.as_slice());
588///                 orders.publish(seed, None).await?;
589///                 let note = OutgoingMessage::new("audit", br#"{"seeded":1}"#.as_slice());
590///                 audit.publish(note, None).await?;
591///                 orders.commit().await?;
592///                 audit.commit().await
593///             });
594///         },
595///     )
596/// }
597/// # }
598/// # fn main() {}
599/// ```
600#[must_use = "a transaction does nothing until settled with commit() or abort()"]
601pub struct RedisTransaction {
602    core: Arc<RedisCore>,
603    buffered: Vec<Buffered>,
604    settled: bool,
605}
606
607impl Debug for RedisTransaction {
608    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
609        f.debug_struct("RedisTransaction")
610            .field("buffered", &self.buffered.len())
611            .field("settled", &self.settled)
612            .finish_non_exhaustive()
613    }
614}
615
616impl Drop for RedisTransaction {
617    fn drop(&mut self) {
618        // Destructors cannot run async work, so a drop can only discard the buffer; the warning
619        // marks that as an abort the caller never wrote.
620        if !self.settled {
621            warn!(
622                target: "ruststream_fred",
623                buffered = self.buffered.len(),
624                "owned transaction dropped without commit or abort; its buffered messages are \
625                 discarded"
626            );
627        }
628    }
629}
630
631impl Transaction for RedisTransaction {
632    /// The same as [`RedisPublisher`]'s: a buffered `XADD` hands the client the same value.
633    type Payload = Take;
634
635    type Error = RedisError;
636    /// The same as [`RedisPublisher`]'s: a buffered `XADD` is the same command, queued, so a
637    /// staged message takes a partition key the way a direct one does.
638    type Options = RedisPublishOptions;
639
640    /// Buffers the `XADD` locally; nothing reaches the server before [`commit`](Self::commit).
641    fn publish(
642        &mut self,
643        msg: OutgoingMessage<'_, BytesMut>,
644        options: Option<&Self::Options>,
645    ) -> impl Future<Output = Result<(), Self::Error>> {
646        let (key, payload, headers) = msg.into_parts();
647        self.buffered.push((
648            key.to_owned(),
649            fields_for_publish(Vec::from(payload), &resolved_headers(headers, options)),
650        ));
651        ready(Ok(()))
652    }
653
654    /// Flushes the buffer as one `MULTI` / `EXEC` block, in publish order.
655    ///
656    /// # Errors
657    ///
658    /// Returns [`RedisError::ShutDown`] when the connection this transaction was opened from is
659    /// gone, or [`RedisError::Publish`] when the block is rejected. A failed commit has still
660    /// consumed the transaction and its buffer is lost; redelivery of the inputs, not
661    /// resubmission of the buffer, is the recovery path.
662    async fn commit(mut self) -> Result<(), Self::Error> {
663        // Settled before the flush: a failed commit has still consumed the transaction (the
664        // buffer is lost per the Transaction contract), so the drop warning must not fire.
665        self.settled = true;
666        flush_block(&self.core, std::mem::take(&mut self.buffered)).await
667    }
668
669    /// Discards the buffer. Nothing was sent to the server, so this cannot fail.
670    fn abort(mut self) -> impl Future<Output = Result<(), Self::Error>> {
671        self.settled = true;
672        ready(Ok(()))
673    }
674}