ruststream_rdkafka/subscription/topic.rs
1//! One topic consumed through one consumer group: the addressable subscription form.
2
3use std::future::{Future, ready};
4
5#[cfg(feature = "asyncapi")]
6use ruststream::asyncapi::Bindings;
7use ruststream::runtime::IntoSource;
8use ruststream::{AddressedCopies, RedeliveryAddress, RedeliveryAddressed, SubscriptionSource};
9
10use super::{Assignment, Commit, GroupSettings, LaneKey, Reader, StartOffset, SubscriptionPlan};
11use crate::broker::ConnectedKafkaBroker;
12use crate::error::KafkaError;
13use crate::subscriber::KafkaSubscriber;
14
15/// A subscription to one Kafka topic through one consumer group.
16///
17/// Everything except the topic name is optional; unset options fall back to the librdkafka
18/// defaults (this crate does not impose its own). The group can also come from
19/// [`KafkaBroker::default_group`](crate::KafkaBroker::default_group); a subscription that ends
20/// up with no group at all is a startup error, because Kafka cannot subscribe without one.
21///
22/// This is the form a deferred `retry_after` copy can be published back to: one topic, and a
23/// publish to it reaches every group reading it. Several topics at once take
24/// [`KafkaTopics`](crate::KafkaTopics) and named partitions take
25/// [`KafkaPartitions`](crate::KafkaPartitions); neither can be reached by a single publish, so
26/// the mount site names where their copies go.
27///
28/// # Examples
29///
30/// ```
31/// use ruststream_rdkafka::prelude::*;
32/// # #[derive(serde::Deserialize)]
33/// # struct Order { id: u64 }
34///
35/// #[subscriber(
36/// KafkaTopic::new("orders")
37/// .group("orders-svc")
38/// .start(StartOffset::Earliest)
39/// .commit(Commit::Tracked)
40/// .assignment(Assignment::CooperativeSticky)
41/// .config("fetch.min.bytes", "1024")
42/// )]
43/// async fn place(order: &Order) -> HandlerOutcome {
44/// tracing::info!(order.id, "accepted");
45/// HandlerOutcome::ack()
46/// }
47///
48/// fn app() -> RustStream {
49/// RustStream::new(AppInfo::new("orders", "0.1.0"))
50/// .with_broker(KafkaBroker::new(["localhost:9092"]), |b| {
51/// b.include(place);
52/// })
53/// }
54/// # let _ = app;
55/// ```
56#[derive(Debug, Clone)]
57pub struct KafkaTopic {
58 topic: String,
59 settings: GroupSettings,
60}
61
62impl KafkaTopic {
63 /// Describes a subscription to `topic` with librdkafka defaults for everything else.
64 #[must_use]
65 pub fn new(topic: impl Into<String>) -> Self {
66 Self {
67 topic: topic.into(),
68 settings: GroupSettings::default(),
69 }
70 }
71
72 /// The consumer group for this subscription, overriding
73 /// [`KafkaBroker::default_group`](crate::KafkaBroker::default_group).
74 #[must_use]
75 pub fn group(mut self, group: impl Into<String>) -> Self {
76 self.settings.group = Some(group.into());
77 self
78 }
79
80 /// Where the group starts when it has no committed offset (see [`StartOffset`]).
81 #[must_use]
82 pub fn start(mut self, start: StartOffset) -> Self {
83 self.settings.start = start;
84 self
85 }
86
87 /// How processed deliveries are committed (see [`Commit`]).
88 #[must_use]
89 pub fn commit(mut self, commit: Commit) -> Self {
90 self.settings.commit = commit;
91 self
92 }
93
94 /// The partition assignment strategy (see [`Assignment`]); unset means the librdkafka
95 /// default (`range,roundrobin`).
96 #[must_use]
97 pub fn assignment(mut self, assignment: Assignment) -> Self {
98 self.settings.assignment = Some(assignment);
99 self
100 }
101
102 /// What drives keyed worker lanes for this subscription (see [`LaneKey`]); the default
103 /// lanes by the source partition, Kafka's native ordering unit.
104 ///
105 /// # Examples
106 ///
107 /// Per-record-key lanes: one tenant's orders never run concurrently, different tenants in
108 /// one partition do.
109 ///
110 /// ```
111 /// use ruststream_rdkafka::prelude::*;
112 /// # #[derive(serde::Deserialize)]
113 /// # struct Order { id: u64 }
114 ///
115 /// #[subscriber(
116 /// KafkaTopic::new("orders")
117 /// .group("orders-svc")
118 /// .commit(Commit::Tracked)
119 /// .lane_key(LaneKey::RecordKey),
120 /// workers(8, by_key)
121 /// )]
122 /// async fn place(order: &Order) -> HandlerOutcome {
123 /// tracing::info!(order.id, "accepted");
124 /// HandlerOutcome::ack()
125 /// }
126 ///
127 /// fn app() -> RustStream {
128 /// RustStream::new(AppInfo::new("orders", "0.1.0"))
129 /// .with_broker(KafkaBroker::new(["localhost:9092"]), |b| {
130 /// b.include(place);
131 /// })
132 /// }
133 /// # let _ = app;
134 /// ```
135 #[must_use]
136 pub fn lane_key(mut self, lane_key: LaneKey) -> Self {
137 self.settings.lane_key = lane_key;
138 self
139 }
140
141 /// Raw librdkafka consumer property passthrough for anything not surfaced as a typed
142 /// option, applied last (it wins over the typed options and the broker-wide config).
143 ///
144 /// The exception is a property the subscription's [`Commit`] mode owns
145 /// (`enable.auto.offset.store`, `enable.auto.commit`) under
146 /// [`Commit::Tracked`](Commit::Tracked) or
147 /// [`Commit::Transactional`](Commit::Transactional): taking it back would leave the
148 /// committed position to librdkafka while every ack decided nothing, so the subscription
149 /// refuses to open instead. [`Commit::Auto`](Commit::Auto) owns neither and takes both.
150 #[must_use]
151 pub fn config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
152 self.settings.config.set(key.into(), value.into());
153 self
154 }
155
156 /// The subscribed topic, which is also the handler metadata name.
157 #[must_use]
158 pub fn topic(&self) -> &str {
159 &self.topic
160 }
161
162 /// What this subscription adds to its channel in the generated `AsyncAPI` document: the
163 /// Kafka topic behind it.
164 #[cfg(feature = "asyncapi")]
165 fn channel_bindings(&self) -> Bindings {
166 crate::bindings::channel(&self.topic)
167 }
168
169 /// What this subscription adds to its `receive` operation: the consumer group it joins, and
170 /// the client id where the raw passthrough names one.
171 #[cfg(feature = "asyncapi")]
172 fn operation_bindings(&self) -> Bindings {
173 crate::bindings::operation(self.settings.group.as_deref(), &self.settings.config)
174 }
175
176 fn into_plan(self) -> Result<SubscriptionPlan, KafkaError> {
177 super::reject_empty(&self.topic)?;
178 super::reject_pattern(&self.topic, "`KafkaTopic`")?;
179 super::reject_commit_mode_clash(&self.topic, &self.settings.commit, &self.settings.config)?;
180 Ok(SubscriptionPlan {
181 name: self.topic.clone(),
182 reader: Reader::Subscribed(vec![self.topic]),
183 settings: self.settings,
184 })
185 }
186}
187
188// The manual path binds a `Handle` body to its source with `subscriber(source, body)`, which
189// takes the descriptor as it is, the way the `#[subscriber(..)]` attribute does.
190impl IntoSource for KafkaTopic {
191 type Source = Self;
192
193 fn into_source(self) -> Self {
194 self
195 }
196}
197
198impl SubscriptionSource<ConnectedKafkaBroker> for KafkaTopic {
199 type Subscriber = KafkaSubscriber;
200 type Copies = AddressedCopies;
201
202 fn name(&self) -> &str {
203 &self.topic
204 }
205
206 async fn subscribe(
207 self,
208 connected: &ConnectedKafkaBroker,
209 ) -> Result<Self::Subscriber, KafkaError> {
210 connected.open(self.into_plan()?).await
211 }
212
213 #[cfg(feature = "asyncapi")]
214 fn channel_bindings(&self) -> Bindings {
215 Self::channel_bindings(self)
216 }
217
218 #[cfg(feature = "asyncapi")]
219 fn operation_bindings(&self) -> Bindings {
220 Self::operation_bindings(self)
221 }
222}
223
224impl RedeliveryAddressed<ConnectedKafkaBroker> for KafkaTopic {
225 /// The topic itself: a record produced to it reaches every group reading it, this
226 /// subscription included.
227 ///
228 /// Answered from the descriptor alone, so nothing is awaited.
229 fn redelivery_address(
230 &self,
231 _connected: &ConnectedKafkaBroker,
232 ) -> impl Future<Output = Result<RedeliveryAddress, KafkaError>> {
233 ready(Ok(RedeliveryAddress::new(self.topic.clone())))
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn a_plain_subscription_plans_one_subscribed_topic() {
243 let plan = KafkaTopic::new("orders")
244 .group("orders-svc")
245 .into_plan()
246 .expect("a literal topic name is a valid single-topic subscription");
247 assert_eq!(plan.name, "orders");
248 assert!(matches!(plan.reader, Reader::Subscribed(names) if names == ["orders"]));
249 }
250
251 // The single-topic form promises a publish address, and a regex is not one, so it refuses
252 // the name instead of reporting an address no record would arrive at.
253 #[test]
254 fn a_pattern_name_is_refused_and_points_at_the_pattern_form() {
255 let err = KafkaTopic::new("^orders\\..*")
256 .into_plan()
257 .expect_err("a topic regex is not a single topic");
258 let KafkaError::InvalidOptions(message) = err else {
259 panic!("a refused name must be an options error");
260 };
261 assert!(
262 message.contains("KafkaTopics::pattern"),
263 "the error must name the form that does subscribe by pattern, got: {message}"
264 );
265 }
266
267 #[test]
268 fn an_empty_topic_name_is_refused() {
269 let err = KafkaTopic::new("")
270 .into_plan()
271 .expect_err("an empty name subscribes to nothing");
272 assert!(matches!(err, KafkaError::InvalidOptions(_)));
273 }
274}