Skip to main content

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}