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}