Skip to main content

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}