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;
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}