krabka_client_consumer/subscription.rs
1//! The subscription of a consumer: Kafka's `subscribe(Collection)`,
2//! `subscribe(Pattern)`, `unsubscribe` and `subscription`.
3//!
4//! The consumer and its coordinator task share the subscription. The consumer
5//! changes it when the application calls `subscribe` or `unsubscribe`. The
6//! coordinator task changes the topics of a pattern subscription when the
7//! cluster metadata changes. Each change that the task sees makes the next
8//! `poll` join the group again, as Kafka's
9//! `ConsumerCoordinator.rejoinNeededOrPending` does when the subscription is
10//! not the joined one.
11
12use std::{collections::BTreeSet, fmt, sync::Arc};
13
14use krabka_protocol::owned::metadata_response;
15use metadata_response::MetadataResponse;
16
17use crate::{consumer::Consumer, error::ConsumerError, fetch_buffer};
18
19/// A topic pattern for [`Consumer::subscribe_pattern`]. Kafka's
20/// `subscribe(java.util.regex.Pattern)`.
21///
22/// The consumer calls the matcher for each topic in the cluster metadata and
23/// subscribes to the topics that match. Pass for example
24/// `move |topic| regex.is_match(topic)`.
25#[derive(Clone)]
26pub struct TopicPattern {
27 matcher: Arc<dyn Fn(&str) -> bool + Send + Sync>,
28}
29
30impl TopicPattern {
31 /// A pattern that matches the topics for which `matcher` returns `true`.
32 pub fn new(matcher: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self {
33 Self {
34 matcher: Arc::new(matcher),
35 }
36 }
37
38 /// Whether `topic` matches.
39 #[must_use]
40 pub fn matches(&self, topic: &str) -> bool {
41 (self.matcher)(topic)
42 }
43}
44
45impl fmt::Debug for TopicPattern {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 f.write_str("TopicPattern")
48 }
49}
50
51/// What a consumer subscribes to.
52#[derive(Clone, Debug, Default)]
53pub(crate) struct Subscription {
54 /// The subscribed topics, sorted. For a pattern, the topics that matched
55 /// the last metadata.
56 pub topics: Vec<String>,
57 /// The pattern of a pattern subscription.
58 pub pattern: Option<TopicPattern>,
59 /// The regular expression of a broker-side pattern subscription of the
60 /// consumer group protocol (KIP-848). Kafka's
61 /// `SubscriptionState.subscribedRe2JPattern`: the group coordinator
62 /// matches it with RE2J, so the consumer does no matching.
63 pub regex: Option<String>,
64 /// Kafka's `exclude.internal.topics`: a pattern does not match an
65 /// internal topic.
66 pub exclude_internal_topics: bool,
67 /// The number of `unsubscribe` calls. The coordinator task leaves the
68 /// group for each one that it did not see, also when a new subscription
69 /// came before it saw the change.
70 pub unsubscribes: u64,
71 /// The number of changes of the application: `subscribe`,
72 /// `subscribe_pattern` and `unsubscribe`. The coordinator task stores the
73 /// topics of a pattern only while this number is the one of its metadata
74 /// request.
75 pub version: u64,
76 /// The consumer has a manual assignment. Kafka's
77 /// `SubscriptionType.USER_ASSIGNED`.
78 pub manual_assignment: bool,
79}
80
81impl Subscription {
82 /// A subscription to `topics`.
83 pub(crate) fn topics(topics: impl IntoIterator<Item = String>, exclude_internal: bool) -> Self {
84 Self {
85 topics: sorted(topics),
86 pattern: None,
87 regex: None,
88 exclude_internal_topics: exclude_internal,
89 unsubscribes: 0,
90 version: 0,
91 manual_assignment: false,
92 }
93 }
94
95 /// Whether the consumer subscribes to nothing. Kafka's
96 /// `SubscriptionState.subscriptionType == NONE`.
97 pub(crate) fn is_none(&self) -> bool {
98 self.topics.is_empty() && self.pattern.is_none() && self.regex.is_none()
99 }
100
101 /// Whether `topic` is subscribed.
102 pub(crate) fn contains(&self, topic: &str) -> bool {
103 self.topics
104 .binary_search_by(|t| t.as_str().cmp(topic))
105 .is_ok()
106 }
107
108 /// Store the topics of an assignment of a regular expression
109 /// subscription. Return whether they changed.
110 ///
111 /// The group coordinator matches the regular expression, so the topics of
112 /// the subscription come from the assignment. They give `poll` the topics
113 /// of its metadata and its fetch filter.
114 pub(crate) fn store_assigned_topics(&mut self, assigned: &[String]) -> bool {
115 if self.regex.is_none() {
116 return false;
117 }
118 let topics = sorted(assigned.iter().cloned());
119 if self.topics == topics {
120 return false;
121 }
122 self.topics = topics;
123 true
124 }
125
126 /// Store `matched` as the topics of the pattern of `version`. Return
127 /// whether the topics changed. A newer change of the application keeps
128 /// its subscription.
129 pub(crate) fn store_pattern_topics(&mut self, version: u64, matched: &[String]) -> bool {
130 if self.version != version || self.pattern.is_none() || self.topics == matched {
131 return false;
132 }
133 self.topics = matched.to_vec();
134 true
135 }
136
137 /// The topics of `metadata` that the pattern matches, sorted. Kafka's
138 /// `ConsumerCoordinator.updatePatternSubscription`: an internal topic
139 /// matches only without `exclude.internal.topics`.
140 pub(crate) fn matching_topics(&self, metadata: &MetadataResponse) -> Option<Vec<String>> {
141 let pattern = self.pattern.as_ref()?;
142 Some(sorted(
143 metadata
144 .topics
145 .iter()
146 .filter(|topic| !(self.exclude_internal_topics && topic.is_internal))
147 .filter_map(|topic| topic.name.clone())
148 .filter(|name| pattern.matches(name)),
149 ))
150 }
151}
152
153fn sorted(topics: impl IntoIterator<Item = String>) -> Vec<String> {
154 topics
155 .into_iter()
156 .collect::<BTreeSet<_>>()
157 .into_iter()
158 .collect()
159}
160
161/// The subscription that the consumer and its coordinator task share.
162pub(crate) type SharedSubscription = Arc<tokio::sync::watch::Sender<Subscription>>;
163
164/// The shared subscription of a new consumer.
165pub(crate) fn shared(
166 topics: Vec<String>,
167 pattern: Option<TopicPattern>,
168 exclude_internal_topics: bool,
169) -> SharedSubscription {
170 Arc::new(tokio::sync::watch::Sender::new(Subscription {
171 pattern,
172 ..Subscription::topics(topics, exclude_internal_topics)
173 }))
174}
175
176/// The `JoinGroup` reason after a subscription change. Kafka's
177/// `ConsumerCoordinator.rejoinNeededOrPending`.
178pub(crate) fn subscription_changed_reason(joined: &[String], now: &[String]) -> String {
179 format!(
180 "the subscription has changed from [{}] to [{}] since the last group join",
181 joined.join(", "),
182 now.join(", ")
183 )
184}
185
186impl Consumer {
187 /// Subscribe to `topics`, in place of the current subscription. Kafka's
188 /// `KafkaConsumer.subscribe(Collection)`.
189 ///
190 /// The next `poll` joins the group again with the new topics. An empty
191 /// list is [`unsubscribe`](Self::unsubscribe).
192 ///
193 /// # Errors
194 ///
195 /// Returns [`ConsumerError::InvalidArgument`] for an empty topic name, and
196 /// the error of the listener call of `unsubscribe`.
197 pub async fn subscribe<I, S>(&mut self, topics: I) -> Result<(), ConsumerError>
198 where
199 I: IntoIterator<Item = S>,
200 S: Into<String>,
201 {
202 let topics: Vec<String> = topics.into_iter().map(Into::into).collect();
203 if topics.iter().any(String::is_empty) {
204 return Err(ConsumerError::InvalidArgument(
205 "Topic collection to subscribe to cannot contain null or empty topic".to_owned(),
206 ));
207 }
208 if topics.is_empty() {
209 return self.unsubscribe().await;
210 }
211 self.require_group_membership()?;
212 if self.subscription.borrow().manual_assignment {
213 return Err(ConsumerError::IllegalState(
214 "Subscription to topics, partitions and pattern are mutually exclusive".to_owned(),
215 ));
216 }
217 self.client.metadata_topics().set(topics.iter().cloned());
218 // Kafka's `subscribe(topics)` calls
219 // `fetcher.clearBufferedDataForUnassignedTopics(topics)`.
220 self.fetch_buffer
221 .retain_topics(&topics.iter().cloned().collect());
222 self.subscription.send_modify(|subscription| {
223 subscription.topics = sorted(topics);
224 subscription.pattern = None;
225 subscription.regex = None;
226 subscription.version += 1;
227 });
228 Ok(())
229 }
230
231 /// Subscribe to the topics that `pattern` matches, in place of the current
232 /// subscription. Kafka's `KafkaConsumer.subscribe(Pattern)`.
233 ///
234 /// The coordinator task matches the pattern against all topics of the
235 /// cluster at once and each `subscription_metadata_refresh_interval`, and
236 /// the next `poll` joins the group again when the matched topics change.
237 ///
238 /// # Errors
239 ///
240 /// Returns [`ConsumerError::InvalidGroupId`] without a group id, and
241 /// [`ConsumerError::IllegalState`] for a consumer without group
242 /// membership.
243 pub fn subscribe_pattern(&mut self, pattern: TopicPattern) -> Result<(), ConsumerError> {
244 self.require_group_membership()?;
245 if self.subscription.borrow().manual_assignment {
246 return Err(ConsumerError::IllegalState(
247 "Subscription to topics, partitions and pattern are mutually exclusive".to_owned(),
248 ));
249 }
250 // Kafka's `subscribe(pattern)` calls
251 // `fetcher.clearBufferedDataForUnassignedPartitions(emptySet())`.
252 self.fetch_buffer = fetch_buffer::FetchBuffer::default();
253 self.subscription.send_modify(|subscription| {
254 subscription.topics.clear();
255 subscription.pattern = Some(pattern);
256 subscription.regex = None;
257 subscription.version += 1;
258 });
259 Ok(())
260 }
261
262 /// Subscribe to the topics that the regular expression `regex` matches,
263 /// in place of the current subscription. Kafka's
264 /// `KafkaConsumer.subscribe(SubscriptionPattern)` (KIP-848).
265 ///
266 /// The group coordinator matches the expression with RE2J and assigns the
267 /// partitions of the topics that match, so the consumer sends no topic
268 /// names and matches nothing itself. The expression must follow the RE2
269 /// syntax; a broker that cannot compile it answers with
270 /// `INVALID_REGULAR_EXPRESSION`, which `poll` returns.
271 ///
272 /// # Errors
273 ///
274 /// Returns [`ConsumerError::InvalidArgument`] for an empty expression,
275 /// [`ConsumerError::InvalidGroupId`] without a group id, and
276 /// [`ConsumerError::IllegalState`] for a consumer with a manual
277 /// assignment or with the classic group protocol.
278 pub fn subscribe_regex(&mut self, regex: &str) -> Result<(), ConsumerError> {
279 if regex.is_empty() {
280 return Err(ConsumerError::InvalidArgument(
281 "Topic pattern to subscribe to cannot be empty".to_owned(),
282 ));
283 }
284 self.require_group_membership()?;
285 if self.group_protocol != crate::GroupProtocol::Consumer {
286 return Err(ConsumerError::IllegalState(
287 crate::consumer_group::RE2J_NEEDS_CONSUMER_PROTOCOL.to_owned(),
288 ));
289 }
290 if self.subscription.borrow().manual_assignment {
291 return Err(ConsumerError::IllegalState(
292 "Subscription to topics, partitions and pattern are mutually exclusive".to_owned(),
293 ));
294 }
295 self.fetch_buffer = fetch_buffer::FetchBuffer::default();
296 self.subscription.send_modify(|subscription| {
297 // The topics of a regular expression subscription are the topics
298 // of its assignment, not a list of the application. They stay
299 // until the next assignment replaces them, so `poll` keeps the
300 // metadata of the partitions that the member still owns.
301 subscription.pattern = None;
302 subscription.regex = Some(regex.to_owned());
303 subscription.version += 1;
304 });
305 Ok(())
306 }
307
308 /// Give up the subscription and the assigned partitions, and leave the
309 /// group. Kafka's `KafkaConsumer.unsubscribe`.
310 ///
311 /// The rebalance listener gets the owned partitions first, as in `close`.
312 /// Then the coordinator task sends `LeaveGroup` with the reason `the
313 /// consumer unsubscribed from all topics`. A `poll` without a subscription
314 /// returns [`ConsumerError::NotSubscribed`].
315 ///
316 /// # Errors
317 ///
318 /// Returns [`ConsumerError::RebalanceListenerFailed`] when the listener
319 /// callback fails. The consumer unsubscribes also then.
320 pub async fn unsubscribe(&mut self) -> Result<(), ConsumerError> {
321 // Kafka's `unsubscribe` runs `onLeavePrepare` and `maybeLeaveGroup`
322 // only with a coordinator, that is with a group.
323 let listener_result = if self.coordinator_handle.is_some() {
324 self.leave_prepare().await
325 } else {
326 Ok(())
327 };
328 self.fetch_buffer = fetch_buffer::FetchBuffer::default();
329 // Kafka's `SubscriptionState.unsubscribe` clears the assignment before
330 // `unsubscribe` returns. The coordinator task also clears it when it
331 // handles the change.
332 {
333 let mut assigned = self.assigned.lock().await;
334 let mut identity = self.commit_identity.lock().await;
335 assigned.clear();
336 identity.ownership_ids.clear();
337 }
338 self.next_offsets.lock().await.clear();
339 self.positions.lock().await.clear();
340 self.end_offsets.lock().await.clear();
341 self.subscription.send_modify(|subscription| {
342 subscription.topics.clear();
343 subscription.pattern = None;
344 subscription.regex = None;
345 subscription.manual_assignment = false;
346 subscription.unsubscribes += 1;
347 subscription.version += 1;
348 });
349 listener_result
350 }
351
352 /// The subscribed topics. For a pattern subscription, the topics that
353 /// matched the last metadata. Kafka's `KafkaConsumer.subscription`.
354 #[must_use]
355 pub fn subscription(&self) -> Vec<String> {
356 self.subscription.borrow().topics.clone()
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 /// The coordinator task stores the topics of a pattern only for the
363 /// subscription that its metadata request matched.
364 #[test]
365 fn pattern_topics_are_stored_only_for_the_matched_version() {
366 let matched = vec!["orders-eu".to_owned()];
367 let pattern = || Subscription {
368 pattern: Some(TopicPattern::new(|topic| topic.starts_with("orders"))),
369 version: 3,
370 ..Subscription::topics(Vec::new(), true)
371 };
372 let mut actual = Vec::new();
373 let mut wanted = Vec::new();
374 for (name, mut subscription, version, expected) in [
375 ("same version", pattern(), 3, (true, matched.clone())),
376 ("a newer change", pattern(), 2, (false, vec![])),
377 (
378 "a topic subscription now",
379 Subscription {
380 version: 3,
381 ..Subscription::topics(vec!["payments".to_owned()], true)
382 },
383 3,
384 (false, vec!["payments".to_owned()]),
385 ),
386 ] {
387 let stored = subscription.store_pattern_topics(version, &matched);
388 actual.push((name, (stored, subscription.topics)));
389 wanted.push((name, expected));
390 }
391 assert2::assert!(actual == wanted);
392 }
393
394 use metadata_response::MetadataResponseTopic;
395
396 use super::*;
397
398 #[test]
399 fn a_pattern_matches_the_topics_of_the_metadata() {
400 let metadata = MetadataResponse {
401 topics: [
402 ("orders-eu", false),
403 ("orders-us", false),
404 ("payments", false),
405 ("orders-internal", true),
406 ]
407 .into_iter()
408 .map(|(name, is_internal)| MetadataResponseTopic {
409 name: Some(name.into()),
410 is_internal,
411 ..Default::default()
412 })
413 .collect(),
414 ..Default::default()
415 };
416 let orders = TopicPattern::new(|topic| topic.starts_with("orders"));
417 let mut actual = Vec::new();
418 let mut wanted = Vec::new();
419 for (name, exclude_internal, expected) in [
420 ("exclude internal", true, vec!["orders-eu", "orders-us"]),
421 (
422 "include internal",
423 false,
424 vec!["orders-eu", "orders-internal", "orders-us"],
425 ),
426 ] {
427 let subscription = Subscription {
428 pattern: Some(orders.clone()),
429 ..Subscription::topics(Vec::new(), exclude_internal)
430 };
431 actual.push((name, subscription.matching_topics(&metadata)));
432 wanted.push((
433 name,
434 Some(expected.into_iter().map(str::to_owned).collect::<Vec<_>>()),
435 ));
436 }
437 assert2::assert!(actual == wanted);
438 }
439}