Skip to main content

ruststream_lapin/
publish_step.rs

1//! Per-message AMQP basic properties: the policy's defaults, and the steps that adjust one
2//! message.
3//!
4//! [`LapinPublishOptions`] is what one publish may differ from the next in - the `priority`, the
5//! per-message `expiration` (TTL), and the delivery mode. Every field is optional: what a call
6//! leaves alone keeps what the publish policy fixed at the mount site.
7//!
8//! ```text
9//! b.include(expedite).out(Alerts, Publish::default().priority(3));  // the default
10//! alerts.message(&alert).to("alerts").priority(9).publish().await?; // this one message
11//! ```
12//!
13//! [`LapinPublishSteps`] puts those steps on the publish builder, bounded on the options type, so
14//! they appear over this crate's publishers and over no others. Nothing wraps the publisher: the
15//! publish still leaves through the mount site's own entry, with the codec and the transforms that
16//! entry named, and the test harness still attributes it to its [`Out`](ruststream::runtime::Out)
17//! slot.
18//!
19//! The values reach the frame as the protocol fields they are, never as headers. Written into the
20//! header table under the protocol's own names (`priority`, `expiration`) they would travel as
21//! table entries `RabbitMQ` reads for neither purpose; that quiet failure is what the steps exist
22//! to prevent.
23
24use std::time::Duration;
25
26use ruststream::runtime::{PublishBuilder, PublishSink};
27
28/// Header carrying the AMQP `priority` property of a DELIVERY.
29///
30/// A delivery reports the property under this name, so a handler reads it from the message
31/// headers. Publishing under it sets no property: the priority of an outgoing message is
32/// [`LapinPublishSteps::priority`] or the policy's own
33/// [`LapinPublish::priority`](crate::LapinPublish::priority).
34///
35/// The property only orders deliveries on a queue declared with `x-max-priority`; elsewhere the
36/// broker carries it to the consumer and nothing more.
37pub const PRIORITY_HEADER: &str = "amqp-priority";
38
39/// Header carrying the AMQP per-message `expiration` property of a DELIVERY: whole milliseconds,
40/// as decimal digits, which is how AMQP spells a TTL.
41///
42/// A delivery reports the property under this name. Publishing under it sets no property: the TTL
43/// of an outgoing message is [`LapinPublishSteps::expiration`] or the policy's own
44/// [`LapinPublish::expiration`](crate::LapinPublish::expiration).
45///
46/// The broker drops the message once the TTL has passed without it being consumed, dead-lettering
47/// it when the queue says so.
48pub const EXPIRATION_HEADER: &str = "amqp-expiration";
49
50/// The AMQP basic properties one publish may differ from the next in.
51///
52/// Every field is optional because a publish carries only what its call site adjusted; the rest is
53/// what the publish policy fixed when it paired the publisher. The policy carries the defaults
54/// ([`LapinPublish::priority`](crate::LapinPublish::priority),
55/// [`expiration`](crate::LapinPublish::expiration),
56/// [`persistent`](crate::LapinPublish::persistent)), the steps of [`LapinPublishSteps`] adjust one
57/// message, and the publisher resolves the two before it builds the frame.
58///
59/// A test reads the value back from the slot view: `tb.out::<Alerts>().with_options(..)` takes
60/// this type, which is why it derives `Debug` and `PartialEq`.
61///
62/// # Examples
63///
64/// ```
65/// # #[cfg(feature = "testing")]
66/// # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
67/// use ruststream::OutSlot;
68/// use ruststream::testing::TestApp;
69/// use ruststream_lapin::prelude::*;
70/// use serde::{Deserialize, Serialize};
71///
72/// #[derive(Debug, Serialize, Deserialize, PartialEq, Outgoing)]
73/// struct Alert {
74///     level: u8,
75/// }
76///
77/// #[derive(OutSlot)]
78/// #[publishes(Alert)]
79/// struct Alerts;
80///
81/// #[subscriber("alerts.raw")]
82/// async fn escalate(
83///     alert: &Alert,
84///     Out(alerts): Out<impl Publisher<Options = LapinPublishOptions>, Alerts>,
85/// ) -> HandlerOutcome {
86///     let sent = alerts.message(alert).to("alerts").priority(alert.level).publish().await;
87///     if sent.is_err() {
88///         return HandlerOutcome::retry();
89///     }
90///     HandlerOutcome::ack()
91/// }
92///
93/// let app = RustStream::new(AppInfo::new("alerts", "0.1.0")).with_broker(
94///     LapinBroker::new("amqp://localhost:5672"),
95///     |b| {
96///         b.include(escalate).out(Alerts, Publish::default()).build();
97///     },
98/// );
99/// let tb = TestApp::start(app).await?;
100///
101/// tb.broker::<LapinBroker>()
102///     .message(&Alert { level: 9 })
103///     .to("alerts.raw")
104///     .publish()
105///     .await?;
106///
107/// tb.out::<Alerts>()
108///     .assert_called_once()
109///     .with_options(&LapinPublishOptions {
110///         priority: Some(9),
111///         ..LapinPublishOptions::default()
112///     });
113/// # Ok(())
114/// # }
115/// ```
116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
117pub struct LapinPublishOptions {
118    /// The AMQP `priority` property (0..=255), or `None` to keep the policy's.
119    pub priority: Option<u8>,
120    /// The AMQP per-message `expiration` (TTL), or `None` to keep the policy's. AMQP counts it in
121    /// whole milliseconds; a shorter non-zero value becomes one millisecond.
122    pub expiration: Option<Duration>,
123    /// Whether the message is marked persistent (delivery mode 2), or `None` to keep the policy's.
124    pub persistent: Option<bool>,
125}
126
127impl LapinPublishOptions {
128    /// What a publish with neither a call site nor a policy carries: persistent, nothing else.
129    ///
130    /// The native delayed redelivery publishes a copy on the delivery's own channel, outside any
131    /// policy, and a copy that outlives a broker restart is what a delay is for.
132    pub(crate) const PERSISTENT: Self = Self {
133        priority: None,
134        expiration: None,
135        persistent: Some(true),
136    };
137
138    /// This value's fields where it has them, `defaults` where it does not.
139    ///
140    /// What a publisher does first: the call site's options over the policy's.
141    #[must_use]
142    pub(crate) fn over(&self, defaults: &Self) -> Self {
143        Self {
144            priority: self.priority.or(defaults.priority),
145            expiration: self.expiration.or(defaults.expiration),
146            persistent: self.persistent.or(defaults.persistent),
147        }
148    }
149}
150
151/// The AMQP basic properties, as steps on the publish builder.
152///
153/// A step adjusts one field for one message and hands the builder back, so a publish reads as one
154/// chain. It reaches every publish surface this crate ships - a publisher, an
155/// [`Out`](ruststream::runtime::Out) slot inside a handler, a transaction scope - because it is
156/// bounded on the options type rather than on a publisher type.
157///
158/// A handler body that takes a step is the one place a body names this crate: bound the slot
159/// `Out<impl Publisher<Options = LapinPublishOptions>, Marker>` and import this crate's prelude.
160///
161/// # Examples
162///
163/// ```
164/// use std::time::Duration;
165///
166/// use ruststream_lapin::prelude::*;
167/// use serde::{Deserialize, Serialize};
168///
169/// #[derive(Deserialize)]
170/// struct Order {
171///     id: u64,
172///     expedited: bool,
173/// }
174///
175/// #[derive(Serialize, Outgoing)]
176/// #[outgoing(name = "shipments")]
177/// struct Shipment {
178///     order_id: u64,
179/// }
180///
181/// #[subscriber("orders")]
182/// async fn ship(
183///     order: &Order,
184///     Out(shipments): Out<impl Publisher<Options = LapinPublishOptions>>,
185/// ) -> HandlerOutcome {
186///     let shipment = Shipment { order_id: order.id };
187///     let sent = if order.expedited {
188///         shipments
189///             .message(&shipment)
190///             .priority(9)
191///             .expiration(Duration::from_secs(30))
192///             .publish()
193///             .await
194///     } else {
195///         shipments.message(&shipment).publish().await
196///     };
197///     if sent.is_err() {
198///         return HandlerOutcome::retry();
199///     }
200///     HandlerOutcome::ack()
201/// }
202///
203/// // Every shipment takes priority 3 from the policy; an expedited one overrides it.
204/// fn app() -> RustStream {
205///     let broker = LapinBroker::new("amqp://localhost:5672");
206///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
207///         b.include(ship)
208///             .out(DefaultSlot, Publish::default().priority(3))
209///             .build();
210///     })
211/// }
212/// ```
213pub trait LapinPublishSteps {
214    /// Sends this one message with the AMQP `priority` property set, whatever the policy's
215    /// default is.
216    ///
217    /// The property only orders deliveries on a queue declared with `x-max-priority`; elsewhere
218    /// the broker carries it to the consumer and nothing more.
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use ruststream_lapin::prelude::*;
224    /// use serde::{Deserialize, Serialize};
225    ///
226    /// #[derive(Deserialize)]
227    /// struct Ticket {
228    ///     id: u64,
229    ///     vip: bool,
230    /// }
231    ///
232    /// #[derive(Serialize, Outgoing)]
233    /// #[outgoing(name = "support.queue")]
234    /// struct Escalation {
235    ///     ticket: u64,
236    /// }
237    ///
238    /// #[subscriber("tickets")]
239    /// async fn escalate(
240    ///     ticket: &Ticket,
241    ///     Out(support): Out<impl Publisher<Options = LapinPublishOptions>>,
242    /// ) -> HandlerOutcome {
243    ///     let priority = if ticket.vip { 9 } else { 1 };
244    ///     let escalation = Escalation { ticket: ticket.id };
245    ///     let sent = support.message(&escalation).priority(priority).publish().await;
246    ///     if sent.is_err() {
247    ///         return HandlerOutcome::retry();
248    ///     }
249    ///     HandlerOutcome::ack()
250    /// }
251    /// ```
252    #[must_use]
253    fn priority(self, priority: u8) -> Self;
254
255    /// Sends this one message with the AMQP per-message `expiration` (TTL) set: the broker drops
256    /// it once `ttl` has passed without it being consumed, dead-lettering it when the queue says
257    /// so.
258    ///
259    /// AMQP counts the TTL in whole milliseconds; a shorter non-zero `ttl` becomes one
260    /// millisecond.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use std::time::Duration;
266    ///
267    /// use ruststream_lapin::prelude::*;
268    /// use serde::{Deserialize, Serialize};
269    ///
270    /// #[derive(Deserialize)]
271    /// struct QuoteRequest {
272    ///     sku: String,
273    /// }
274    ///
275    /// #[derive(Serialize, Outgoing)]
276    /// #[outgoing(name = "quotes")]
277    /// struct Quote {
278    ///     sku: String,
279    ///     cents: u64,
280    /// }
281    ///
282    /// // A price is only good for half a minute; nobody should act on a stale one.
283    /// #[subscriber("quote.requests")]
284    /// async fn price(
285    ///     request: &QuoteRequest,
286    ///     Out(quotes): Out<impl Publisher<Options = LapinPublishOptions>>,
287    /// ) -> HandlerOutcome {
288    ///     let quote = Quote {
289    ///         sku: request.sku.clone(),
290    ///         cents: 1_999,
291    ///     };
292    ///     let sent = quotes
293    ///         .message(&quote)
294    ///         .expiration(Duration::from_secs(30))
295    ///         .publish()
296    ///         .await;
297    ///     if sent.is_err() {
298    ///         return HandlerOutcome::retry();
299    ///     }
300    ///     HandlerOutcome::ack()
301    /// }
302    /// ```
303    #[must_use]
304    fn expiration(self, ttl: Duration) -> Self;
305
306    /// Sends this one message persistent (delivery mode 2) or transient (1), whatever the
307    /// policy's default is.
308    ///
309    /// A persistent message on a durable queue survives a broker restart. The publish policies
310    /// default to persistent; [`LapinRequest`](crate::LapinRequest) defaults to transient,
311    /// because a request nobody is waiting for after the timeout gains nothing from surviving a
312    /// restart.
313    ///
314    /// # Examples
315    ///
316    /// ```
317    /// use ruststream_lapin::prelude::*;
318    /// use serde::{Deserialize, Serialize};
319    ///
320    /// #[derive(Deserialize)]
321    /// struct Order {
322    ///     id: u64,
323    /// }
324    ///
325    /// #[derive(Serialize, Outgoing)]
326    /// #[outgoing(name = "metrics")]
327    /// struct Sample {
328    ///     order_id: u64,
329    /// }
330    ///
331    /// // The order is the record; a lost sample after a broker restart costs nothing.
332    /// #[subscriber("orders")]
333    /// async fn measure(
334    ///     order: &Order,
335    ///     Out(metrics): Out<impl Publisher<Options = LapinPublishOptions>>,
336    /// ) -> HandlerOutcome {
337    ///     let sample = Sample { order_id: order.id };
338    ///     let sent = metrics.message(&sample).persistent(false).publish().await;
339    ///     if sent.is_err() {
340    ///         return HandlerOutcome::retry();
341    ///     }
342    ///     HandlerOutcome::ack()
343    /// }
344    /// ```
345    #[must_use]
346    fn persistent(self, persistent: bool) -> Self;
347}
348
349impl<Sink, Body, Enc, Hdrs, Dest> LapinPublishSteps for PublishBuilder<Sink, Body, Enc, Hdrs, Dest>
350where
351    Sink: PublishSink<Options = LapinPublishOptions>,
352{
353    fn priority(mut self, priority: u8) -> Self {
354        self.options_mut()
355            .get_or_insert_with(LapinPublishOptions::default)
356            .priority = Some(priority);
357        self
358    }
359
360    fn expiration(mut self, ttl: Duration) -> Self {
361        self.options_mut()
362            .get_or_insert_with(LapinPublishOptions::default)
363            .expiration = Some(ttl);
364        self
365    }
366
367    fn persistent(mut self, persistent: bool) -> Self {
368        self.options_mut()
369            .get_or_insert_with(LapinPublishOptions::default)
370            .persistent = Some(persistent);
371        self
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::{Duration, LapinPublishOptions};
378
379    #[test]
380    fn a_call_site_field_wins_over_the_policy_and_the_rest_is_kept() {
381        let defaults = LapinPublishOptions {
382            priority: Some(3),
383            expiration: Some(Duration::from_mins(1)),
384            persistent: Some(true),
385        };
386        let call = LapinPublishOptions {
387            priority: Some(9),
388            ..LapinPublishOptions::default()
389        };
390
391        let resolved = call.over(&defaults);
392        assert_eq!(resolved.priority, Some(9));
393        assert_eq!(resolved.expiration, Some(Duration::from_mins(1)));
394        assert_eq!(resolved.persistent, Some(true));
395    }
396
397    #[test]
398    fn a_publish_that_adjusted_nothing_is_the_policy_alone() {
399        let defaults = LapinPublishOptions {
400            priority: Some(3),
401            ..LapinPublishOptions::default()
402        };
403
404        assert_eq!(LapinPublishOptions::default().over(&defaults), defaults);
405    }
406}