Skip to main content

ruststream_fred/
partition.rs

1//! The partition key: a per-message option on this crate's publishers, set by a step on the
2//! publish builder.
3//!
4//! Redis has no partition of its own, so the resolved key travels as the well-known
5//! [`PARTITION_KEY_HEADER`] header - the wire form the consumer side's
6//! [`Partitioned`](ruststream::Partitioned) reads back, on all three transports.
7
8use ruststream::HeaderMap;
9use ruststream::runtime::{PublishBuilder, PublishSink};
10
11use crate::message::PARTITION_KEY_HEADER;
12
13/// The per-message settings of every publisher and transaction in this crate.
14///
15/// One field, the partition key, and it is optional like every field of an options type: a publish
16/// that names no step carries no options at all, and nothing is written into its headers.
17///
18/// The type is the bound that keeps this crate's builder steps off another broker's publisher, and
19/// it is what a handler body names when it adjusts a setting
20/// (`Out<impl Publisher<Options = RedisPublishOptions>, Marker>`). A test reads the value back off
21/// the slot view with `with_options`.
22///
23/// # Examples
24///
25/// ```
26/// # use std::error::Error;
27/// # #[cfg(feature = "testing")]
28/// # mod demo {
29/// use std::error::Error;
30///
31/// use ruststream::testing::TestApp;
32/// use ruststream_fred::stream::prelude::*;
33/// use serde::{Deserialize, Serialize};
34///
35/// #[derive(Deserialize, Serialize, Outgoing)]
36/// struct Order {
37///     tenant: String,
38///     id: u64,
39/// }
40///
41/// #[derive(OutSlot)]
42/// #[publishes(Order)]
43/// struct Ledger;
44///
45/// #[subscriber(RedisStream::new("orders.in").group("workers"))]
46/// async fn forward(
47///     order: &Order,
48///     Out(ledger): Out<impl Publisher<Options = RedisPublishOptions>, Ledger>,
49/// ) -> HandlerOutcome {
50///     let sent = ledger
51///         .message(order)
52///         .to("orders.keyed")
53///         .partition_key(&order.tenant)
54///         .publish()
55///         .await;
56///     match sent {
57///         Ok(_) => HandlerOutcome::ack(),
58///         Err(_) => HandlerOutcome::retry(),
59///     }
60/// }
61///
62/// pub fn app() -> impl App<State = ()> {
63///     RustStream::new(AppInfo::new("orders", "0.1.0"))
64///         .with_broker(RedisBroker::standalone("redis://localhost:6379"), |b| {
65///             b.include(forward).out(Ledger, Publish).build();
66///         })
67/// }
68///
69/// pub async fn the_ledger_is_keyed_by_tenant() -> Result<(), Box<dyn Error>> {
70///     let tb = TestApp::start(app()).await?;
71///     let order = Order {
72///         tenant: "tenant-a".to_owned(),
73///         id: 7,
74///     };
75///     tb.broker::<RedisBroker>()
76///         .message(&order)
77///         .to("orders.in")
78///         .publish()
79///         .await?;
80///
81///     tb.out::<Ledger>()
82///         .assert_called_once()
83///         .with_options(&RedisPublishOptions {
84///             partition_key: Some(b"tenant-a".to_vec()),
85///             ..RedisPublishOptions::default()
86///         });
87///     Ok(())
88/// }
89/// # }
90/// # #[cfg(feature = "testing")]
91/// # fn main() -> Result<(), Box<dyn Error>> {
92/// #     tokio::runtime::Runtime::new()?.block_on(demo::the_ledger_is_keyed_by_tenant())
93/// # }
94/// # #[cfg(not(feature = "testing"))]
95/// # fn main() {}
96/// ```
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct RedisPublishOptions {
99    /// The key this one message is partitioned by, or `None` to send it unkeyed.
100    ///
101    /// Opaque bytes: the runtime hashes them to pick a dispatch lane, and never interprets them.
102    pub partition_key: Option<Vec<u8>>,
103
104    /// Whether this message joins the round of the delivery being handled, on a `.pipeline()`
105    /// subscription, instead of leaving at once.
106    ///
107    /// The [`InRound`](crate::pipeline::InRound) transform sets it on a reply; a message sent
108    /// outside a pipelined delivery's handling leaves at once whatever it says.
109    pub join_round: bool,
110}
111
112/// The steps this crate adds to the publish builder.
113///
114/// Import it from any of this crate's preludes to reach [`partition_key`](Self::partition_key) on
115/// a publish over a Redis publisher. The impl is bounded on [`RedisPublishOptions`], so the step
116/// does not appear on a builder over another broker's publisher.
117///
118/// # Examples
119///
120/// ```
121/// # mod demo {
122/// use ruststream_fred::stream::prelude::*;
123/// use serde::{Deserialize, Serialize};
124///
125/// #[derive(Deserialize, Serialize, Outgoing)]
126/// #[outgoing(name = "orders")]
127/// struct Order {
128///     tenant: String,
129///     id: u64,
130/// }
131///
132/// #[derive(OutSlot)]
133/// #[publishes(Order)]
134/// struct Orders;
135///
136/// // Every order of one tenant lands in the same `workers(n, by_key)` lane downstream, so their
137/// // relative order is preserved while other tenants run in parallel.
138/// #[subscriber(RedisStream::new("orders.in").group("intake"))]
139/// async fn intake(
140///     order: &Order,
141///     Out(orders): Out<impl Publisher<Options = RedisPublishOptions>, Orders>,
142/// ) -> HandlerOutcome {
143///     match orders.message(order).partition_key(&order.tenant).publish().await {
144///         Ok(_) => HandlerOutcome::ack(),
145///         Err(_) => HandlerOutcome::retry(),
146///     }
147/// }
148///
149/// #[ruststream::app]
150/// fn app() -> impl App {
151///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
152///         RedisBroker::standalone("redis://localhost:6379"),
153///         |b| {
154///             b.include(intake).out(Orders, Publish).build();
155///         },
156///     )
157/// }
158/// # }
159/// # fn main() {}
160/// ```
161pub trait RedisPublishSteps {
162    /// Sends this one message under `key`, whatever the rest of the chain says.
163    ///
164    /// The key feeds the runtime's keyed worker lanes (`workers(n, by_key)`): deliveries sharing a
165    /// key are dispatched to the same lane, so their relative order survives concurrency. It
166    /// reaches the consumer as the [`PARTITION_KEY_HEADER`](crate::PARTITION_KEY_HEADER) header,
167    /// written over whatever the call site itself put under that name.
168    #[must_use]
169    fn partition_key(self, key: impl AsRef<[u8]>) -> Self;
170}
171
172impl<Sink, Body, Enc, Hdrs, Dest> RedisPublishSteps for PublishBuilder<Sink, Body, Enc, Hdrs, Dest>
173where
174    Sink: PublishSink<Options = RedisPublishOptions>,
175{
176    fn partition_key(mut self, key: impl AsRef<[u8]>) -> Self {
177        self.options_mut()
178            .get_or_insert_with(RedisPublishOptions::default)
179            .partition_key = Some(key.as_ref().to_vec());
180        self
181    }
182}
183
184/// The headers a publish leaves with, once `options` are resolved over the ones it carries.
185///
186/// A step wins over the header the call site wrote itself, because it names this one message while
187/// the header map may be a contract the message type declares. A publish no step touched keeps its
188/// headers untouched, so writing [`PARTITION_KEY_HEADER`] by hand stays the portable spelling.
189/// The map arrives owned because a publish owns it: it is what the transforms filled, taken out
190/// of the outgoing message, so resolving a step writes into it instead of copying it.
191pub(crate) fn resolved_headers(
192    mut headers: HeaderMap,
193    options: Option<&RedisPublishOptions>,
194) -> HeaderMap {
195    if let Some(key) = options.and_then(|options| options.partition_key.as_deref()) {
196        headers.insert(PARTITION_KEY_HEADER, key.to_vec());
197    }
198    headers
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn an_unstepped_publish_keeps_its_own_headers() {
207        let mut headers = HeaderMap::new();
208        headers.insert(PARTITION_KEY_HEADER, "call-site");
209
210        let resolved = resolved_headers(headers, None);
211        assert_eq!(
212            resolved.get(PARTITION_KEY_HEADER),
213            Some(b"call-site".as_slice())
214        );
215    }
216
217    #[test]
218    fn a_step_writes_over_the_call_sites_own_header() {
219        let mut headers = HeaderMap::new();
220        headers.insert(PARTITION_KEY_HEADER, "call-site");
221        headers.insert("trace-id", "abc");
222
223        let options = RedisPublishOptions {
224            partition_key: Some(b"tenant-a".to_vec()),
225            ..RedisPublishOptions::default()
226        };
227        let resolved = resolved_headers(headers, Some(&options));
228
229        assert_eq!(
230            resolved.get(PARTITION_KEY_HEADER),
231            Some(b"tenant-a".as_slice())
232        );
233        // Unrelated entries travel untouched next to the key.
234        assert_eq!(resolved.get("trace-id"), Some(b"abc".as_slice()));
235    }
236}