ruststream_rdkafka/subscription/partitions.rs
1//! Named partitions of one topic, assigned without joining a consumer group.
2
3#[cfg(feature = "asyncapi")]
4use ruststream::asyncapi::Bindings;
5use ruststream::runtime::IntoSource;
6use ruststream::{NamedCopies, SubscriptionSource};
7
8use super::{Commit, GroupSettings, LaneKey, Reader, StartOffset, SubscriptionPlan};
9use crate::broker::ConnectedKafkaBroker;
10use crate::error::KafkaError;
11use crate::subscriber::KafkaSubscriber;
12
13/// A subscription that takes exactly the partitions it names, without joining a consumer group
14/// and without rebalancing.
15///
16/// That fits a reader pinned to a partition, an inspection or replay tool, and a
17/// one-consumer-per-partition deployment. A group stays optional and changes only how offsets
18/// are handled: with one named the consumer commits into the group without joining it, and
19/// without one commits are off, so the start offset has to be explicit.
20///
21/// This form reads a subset of a topic, and the partitioner places a published record wherever
22/// the key sends it, so no publish addresses it: a registration that retries names the
23/// destination itself with `.out_retry(policy).to("orders.retry")`. Reading the whole topic is
24/// [`KafkaTopic`](crate::KafkaTopic), which addresses its own copies.
25///
26/// # Examples
27///
28/// An inspection reader pinned to partition 0, with no group side effects:
29///
30/// ```
31/// use ruststream_rdkafka::context::keys::Offset;
32/// use ruststream_rdkafka::prelude::*;
33/// # #[derive(serde::Deserialize)]
34/// # struct Order { id: u64 }
35///
36/// #[subscriber(KafkaPartitions::new("orders", [0]).start(StartOffset::Earliest))]
37/// async fn inspect(order: &Order, Ctx(offset): Ctx<Offset>) -> HandlerOutcome {
38/// tracing::info!(order.id, %offset, "read from partition 0");
39/// HandlerOutcome::ack()
40/// }
41///
42/// fn app() -> RustStream {
43/// RustStream::new(AppInfo::new("inspector", "0.1.0"))
44/// .with_broker(KafkaBroker::new(["localhost:9092"]), |b| {
45/// b.include(inspect);
46/// })
47/// }
48/// # let _ = app;
49/// ```
50#[derive(Debug, Clone)]
51pub struct KafkaPartitions {
52 topic: String,
53 partitions: Vec<i32>,
54 settings: GroupSettings,
55}
56
57impl KafkaPartitions {
58 /// Describes a reader over exactly `partitions` of `topic`.
59 #[must_use]
60 pub fn new(topic: impl Into<String>, partitions: impl IntoIterator<Item = i32>) -> Self {
61 Self {
62 topic: topic.into(),
63 partitions: partitions.into_iter().collect(),
64 settings: GroupSettings::default(),
65 }
66 }
67
68 /// The consumer group whose committed offsets this reader uses, overriding
69 /// [`KafkaBroker::default_group`](crate::KafkaBroker::default_group). The reader commits
70 /// into the group without ever joining it.
71 #[must_use]
72 pub fn group(mut self, group: impl Into<String>) -> Self {
73 self.settings.group = Some(group.into());
74 self
75 }
76
77 /// Where the reader starts (see [`StartOffset`]). Without a group,
78 /// [`StartOffset::Committed`] has nothing to resume from and is a startup error.
79 #[must_use]
80 pub fn start(mut self, start: StartOffset) -> Self {
81 self.settings.start = start;
82 self
83 }
84
85 /// How processed deliveries are committed (see [`Commit`]).
86 /// [`Commit::Transactional`] is a startup error here: an exactly-once pipeline commits
87 /// through the consumer-group protocol this form does not take part in.
88 #[must_use]
89 pub fn commit(mut self, commit: Commit) -> Self {
90 self.settings.commit = commit;
91 self
92 }
93
94 /// What drives keyed worker lanes for this reader (see [`LaneKey`]). Under the default each
95 /// assigned partition gets a lane of its own.
96 #[must_use]
97 pub fn lane_key(mut self, lane_key: LaneKey) -> Self {
98 self.settings.lane_key = lane_key;
99 self
100 }
101
102 /// Raw librdkafka consumer property passthrough, applied last (it wins over the typed
103 /// options and the broker-wide config).
104 ///
105 /// A property the subscription's [`Commit`] mode owns is the exception; see
106 /// [`KafkaTopic::config`](crate::KafkaTopic::config).
107 #[must_use]
108 pub fn config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
109 self.settings.config.set(key.into(), value.into());
110 self
111 }
112
113 /// The topic these partitions belong to, which is also the handler metadata name.
114 #[must_use]
115 pub fn topic(&self) -> &str {
116 &self.topic
117 }
118
119 /// The partitions this reader takes.
120 #[must_use]
121 pub fn partitions(&self) -> &[i32] {
122 &self.partitions
123 }
124
125 /// What this reader adds to its channel in the generated `AsyncAPI` document: the Kafka
126 /// topic its partitions belong to.
127 #[cfg(feature = "asyncapi")]
128 fn channel_bindings(&self) -> Bindings {
129 crate::bindings::channel(&self.topic)
130 }
131
132 /// What this reader adds to its `receive` operation: the group it commits into, and the
133 /// client id where the raw passthrough names one.
134 #[cfg(feature = "asyncapi")]
135 fn operation_bindings(&self) -> Bindings {
136 crate::bindings::operation(self.settings.group.as_deref(), &self.settings.config)
137 }
138
139 fn into_plan(self) -> Result<SubscriptionPlan, KafkaError> {
140 super::reject_empty(&self.topic)?;
141 super::reject_pattern(&self.topic, "`KafkaPartitions`")?;
142 if self.partitions.is_empty() {
143 return Err(KafkaError::InvalidOptions(format!(
144 "manual assignment of {:?} names no partition, so the reader would take \
145 nothing; name the partitions it reads",
146 self.topic,
147 )));
148 }
149 super::reject_commit_mode_clash(&self.topic, &self.settings.commit, &self.settings.config)?;
150 Ok(SubscriptionPlan {
151 name: self.topic.clone(),
152 reader: Reader::Assigned {
153 topic: self.topic,
154 partitions: self.partitions,
155 },
156 settings: self.settings,
157 })
158 }
159}
160
161// The manual path binds a `Handle` body to its source with `subscriber(source, body)`, which
162// takes the descriptor as it is, the way the `#[subscriber(..)]` attribute does.
163impl IntoSource for KafkaPartitions {
164 type Source = Self;
165
166 fn into_source(self) -> Self {
167 self
168 }
169}
170
171impl SubscriptionSource<ConnectedKafkaBroker> for KafkaPartitions {
172 type Subscriber = KafkaSubscriber;
173 type Copies = NamedCopies;
174
175 fn name(&self) -> &str {
176 &self.topic
177 }
178
179 async fn subscribe(
180 self,
181 connected: &ConnectedKafkaBroker,
182 ) -> Result<Self::Subscriber, KafkaError> {
183 connected.open(self.into_plan()?).await
184 }
185
186 #[cfg(feature = "asyncapi")]
187 fn channel_bindings(&self) -> Bindings {
188 Self::channel_bindings(self)
189 }
190
191 #[cfg(feature = "asyncapi")]
192 fn operation_bindings(&self) -> Bindings {
193 Self::operation_bindings(self)
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn a_reader_plans_the_partitions_it_names() {
203 let plan = KafkaPartitions::new("orders", [0, 2])
204 .start(StartOffset::Earliest)
205 .into_plan()
206 .expect("two partitions of one topic are a valid assignment");
207 assert_eq!(plan.name, "orders");
208 let Reader::Assigned { topic, partitions } = plan.reader else {
209 panic!("a manual assignment must plan an assigned reader");
210 };
211 assert_eq!(topic, "orders");
212 assert_eq!(partitions, [0, 2]);
213 }
214
215 #[test]
216 fn naming_no_partition_is_refused() {
217 let err = KafkaPartitions::new("orders", [])
218 .into_plan()
219 .expect_err("a reader over no partition reads nothing");
220 assert!(matches!(err, KafkaError::InvalidOptions(_)));
221 }
222}