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