Skip to main content

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}