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}