Skip to main content

krabka_client_admin/
group_deletions.rs

1//! Batched group deletion: [`AdminClient::delete_consumer_groups`],
2//! [`AdminClient::delete_share_groups`] and
3//! [`AdminClient::delete_streams_groups`].
4//!
5//! Each call finds the coordinator of every group with one batched
6//! `FindCoordinator` request and sends one `DeleteGroups` request to each
7//! coordinator, as Apache Kafka's `DeleteGroupsHandler` does with its
8//! `CoordinatorStrategy`. A request to a coordinator that
9//! fails or loses its connection finds the coordinator again.
10
11use std::collections::BTreeMap;
12
13use krabka_protocol::owned::delete_groups_request::DeleteGroupsRequest;
14
15use crate::{
16    AdminClient, KafkaError,
17    group_coordinators::{GroupOutcome, group_error_outcome},
18    group_descriptions::request_error_outcomes,
19};
20
21/// The per-group results of a delete call: one entry for each requested
22/// group, `Ok(())` for a deleted group.
23pub type GroupDeletions = BTreeMap<String, Result<(), KafkaError>>;
24
25impl AdminClient {
26    /// Deletes empty consumer groups with `DeleteGroups`, as Apache Kafka's
27    /// `deleteConsumerGroups` does with its `DeleteConsumerGroupsHandler`.
28    ///
29    /// The call finds the coordinator of every group with one batched
30    /// `FindCoordinator` request and sends one `DeleteGroups` request to each
31    /// coordinator with the groups that it coordinates.
32    /// `COORDINATOR_LOAD_IN_PROGRESS` (14) sends a group to the same
33    /// coordinator again, and `COORDINATOR_NOT_AVAILABLE` (15) and
34    /// `NOT_COORDINATOR` (16) find its coordinator again. A lost
35    /// connection finds the coordinator again. The call waits with Kafka's
36    /// backoff between rounds and stops at `default.api.timeout.ms` (60 s),
37    /// where each pending group gets `REQUEST_TIMED_OUT` (7).
38    ///
39    /// # Errors
40    /// The call itself does not fail. Each group gets its own [`KafkaError`],
41    /// such as `NON_EMPTY_GROUP` (68), `GROUP_ID_NOT_FOUND` (69) or
42    /// `REQUEST_TIMED_OUT` (7) at the deadline.
43    pub async fn delete_consumer_groups(&self, groups: &[&str]) -> GroupDeletions {
44        self.delete_groups("deleteConsumerGroups", groups).await
45    }
46
47    /// Deletes empty share groups. Apache Kafka's `deleteShareGroups` uses a
48    /// `DeleteShareGroupsHandler` that builds the same `DeleteGroups` request
49    /// as `deleteConsumerGroups`.
50    ///
51    /// # Errors
52    /// See [`AdminClient::delete_consumer_groups`].
53    pub async fn delete_share_groups(&self, groups: &[&str]) -> GroupDeletions {
54        self.delete_groups("deleteShareGroups", groups).await
55    }
56
57    /// Deletes empty streams groups. Apache Kafka's `deleteStreamsGroups`
58    /// delegates to `deleteConsumerGroups`.
59    ///
60    /// # Errors
61    /// See [`AdminClient::delete_consumer_groups`].
62    pub async fn delete_streams_groups(&self, groups: &[&str]) -> GroupDeletions {
63        self.delete_consumer_groups(groups).await
64    }
65
66    /// The `DeleteGroups` call of Kafka's `DeleteGroupsHandler`.
67    async fn delete_groups(&self, api: &'static str, groups: &[&str]) -> GroupDeletions {
68        self.call_group_coordinators(api, groups, |coordinator, keys| async move {
69            let connection = self.connect_coordinator(&coordinator).await?;
70            let request = DeleteGroupsRequest {
71                groups_names: keys.clone(),
72                ..Default::default()
73            };
74            Ok(match connection.send(request).await {
75                Ok(response) => response
76                    .results
77                    .into_iter()
78                    .map(|result| {
79                        let outcome = match result.error_code {
80                            0 => GroupOutcome::Done(Ok(())),
81                            code => group_error_outcome(code, None, &[]),
82                        };
83                        (result.group_id, outcome)
84                    })
85                    .collect(),
86                Err(error) => request_error_outcomes(&keys, &error.into()),
87            })
88        })
89        .await
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use std::{
96        net::SocketAddr,
97        sync::{Arc, Mutex},
98    };
99
100    use assert2::assert;
101    use krabka_client_core::MockReply;
102    use krabka_protocol::owned::{
103        delete_groups_request,
104        delete_groups_response::{DeletableGroupResult, DeleteGroupsResponse},
105        find_coordinator_request,
106    };
107
108    use super::*;
109    use crate::{
110        group_coordinators::{
111            kafka_error,
112            test_support::{GroupCluster, Lookup, group_cluster},
113        },
114        partition_leaders::test_support::{ApiVersionRange, decode_request, encode_response},
115    };
116
117    fn names(names: &[&str]) -> Vec<String> {
118        names.iter().map(|name| (*name).to_owned()).collect()
119    }
120
121    async fn delete_groups_cluster(find_coordinator_max: i16) -> GroupCluster {
122        group_cluster()
123            .count(1)
124            .apis(vec![
125                ApiVersionRange::builder()
126                    .api_key(delete_groups_request::API_KEY)
127                    .max_version(2)
128                    .build(),
129                ApiVersionRange::builder()
130                    .api_key(find_coordinator_request::API_KEY)
131                    .max_version(find_coordinator_max)
132                    .build(),
133            ])
134            .lookup(|_, _| Lookup::At(0))
135            .answer(mock_delete_groups)
136            .call()
137            .await
138    }
139
140    /// Kafka's `DeleteGroupsHandler` batches the groups of one coordinator,
141    /// retries 14 on the same coordinator, finds the coordinator again on 15
142    /// and 16, and fails every other code. A `FindCoordinator` error that
143    /// Kafka does not retry fails the group without a `DeleteGroups` request.
144    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
145    async fn delete_groups_batches_and_retries_as_kafka_does() {
146        #[derive(Clone, Copy, Debug)]
147        enum Call {
148            Consumer,
149            Share,
150            Streams,
151        }
152        for call in [Call::Consumer, Call::Share, Call::Streams] {
153            // "moved" is on broker 1 first, and then on broker 0.
154            let codes = Arc::new(Mutex::new(maplit::btreemap! {
155                "done" => vec![0],
156                "loading" => vec![14, 0],
157                "moved" => vec![16, 0],
158                "busy" => vec![68],
159            }));
160            let cluster = group_cluster()
161                .count(2)
162                .apis(vec![
163                    ApiVersionRange::builder()
164                        .api_key(delete_groups_request::API_KEY)
165                        .max_version(2)
166                        .build(),
167                ])
168                .lookup(|group, lookups| match (group, lookups) {
169                    ("denied", _) => Lookup::Error(30),
170                    ("moved", 0) => Lookup::At(1),
171                    _ => Lookup::At(0),
172                })
173                .answer(move |_, api_key, version, body, _| {
174                    if api_key != delete_groups_request::API_KEY {
175                        return MockReply::Silent;
176                    }
177                    let request: DeleteGroupsRequest = decode_request(body, version, version >= 2);
178                    let mut codes = codes.lock().expect("codes lock");
179                    let results = request
180                        .groups_names
181                        .iter()
182                        .map(|group| {
183                            let script = codes.get_mut(group.as_str()).expect("scripted group");
184                            let error_code = if script.len() > 1 {
185                                script.remove(0)
186                            } else {
187                                script[0]
188                            };
189                            DeletableGroupResult {
190                                group_id: group.clone(),
191                                error_code,
192                                ..Default::default()
193                            }
194                        })
195                        .collect();
196                    MockReply::Respond(encode_response(
197                        &DeleteGroupsResponse {
198                            results,
199                            ..Default::default()
200                        },
201                        version,
202                        version >= 2,
203                    ))
204                })
205                .call()
206                .await;
207            let admin = cluster.admin(krabka_units::secs(5)).await;
208            let groups = ["done", "loading", "moved", "busy", "denied"];
209
210            let result = match call {
211                Call::Consumer => admin.delete_consumer_groups(&groups).await,
212                Call::Share => admin.delete_share_groups(&groups).await,
213                Call::Streams => admin.delete_streams_groups(&groups).await,
214            };
215
216            let seen = cluster.stop();
217            let requests = seen
218                .requests
219                .iter()
220                .map(|request| {
221                    let body: DeleteGroupsRequest =
222                        decode_request(&request.body, request.version, request.version >= 2);
223                    (request.broker, request.version, body.groups_names)
224                })
225                .collect::<Vec<_>>();
226            let lookups = seen
227                .find_coordinator
228                .into_iter()
229                .map(|(_, keys)| keys)
230                .collect::<Vec<_>>();
231            assert!(
232                (result, lookups)
233                    == (
234                        maplit::btreemap! {
235                            "busy".to_owned() => Err(kafka_error(68, None)),
236                            "denied".to_owned() => Err(kafka_error(30, None)),
237                            "done".to_owned() => Ok(()),
238                            "loading".to_owned() => Ok(()),
239                            "moved".to_owned() => Ok(()),
240                        },
241                        vec![
242                            names(&["busy", "denied", "done", "loading", "moved"]),
243                            names(&["moved"]),
244                        ],
245                    ),
246                "case {call:?}"
247            );
248            // The first round sends one request to each coordinator; the
249            // second retries "loading" and the moved "moved" together.
250            let mut first_round = requests[..2].to_vec();
251            first_round.sort();
252            assert!(
253                (first_round, requests[2..].to_vec())
254                    == (
255                        vec![
256                            (0, 2, names(&["busy", "done", "loading"])),
257                            (1, 2, names(&["moved"])),
258                        ],
259                        vec![(0, 2, names(&["loading", "moved"]))],
260                    ),
261                "case {call:?}"
262            );
263        }
264    }
265
266    fn mock_delete_groups(
267        _broker: usize,
268        api_key: i16,
269        version: i16,
270        body: &[u8],
271        _addr: SocketAddr,
272    ) -> MockReply {
273        if api_key != delete_groups_request::API_KEY {
274            return MockReply::Silent;
275        }
276        let request: DeleteGroupsRequest = decode_request(body, version, version >= 2);
277        MockReply::Respond(encode_response(
278            &DeleteGroupsResponse {
279                results: request
280                    .groups_names
281                    .into_iter()
282                    .map(|group_id| DeletableGroupResult {
283                        group_id,
284                        ..Default::default()
285                    })
286                    .collect(),
287                ..Default::default()
288            },
289            version,
290            version >= 2,
291        ))
292    }
293
294    /// A broker at `FindCoordinator` v4 or later gets one lookup for every
295    /// group, and a broker below v4 gets one lookup for each group, sent all at
296    /// once. Each row compares the version and keys of every lookup, sorted
297    /// because concurrent lookups arrive in any order.
298    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
299    async fn group_lookups_batch_from_find_coordinator_v4_and_fan_out_below() {
300        for (name, find_coordinator_max, expected) in [
301            ("v6 broker batches", 6, vec![(4, names(&["a", "b", "c"]))]),
302            ("v4 broker batches", 4, vec![(4, names(&["a", "b", "c"]))]),
303            (
304                "v3 broker looks up each group",
305                3,
306                vec![(3, names(&["a"])), (3, names(&["b"])), (3, names(&["c"]))],
307            ),
308        ] {
309            let cluster = delete_groups_cluster(find_coordinator_max).await;
310            let admin = cluster.admin(krabka_units::secs(5)).await;
311
312            let result = admin.delete_consumer_groups(&["a", "b", "c"]).await;
313
314            let mut lookups = cluster
315                .stop()
316                .find_coordinator
317                .into_iter()
318                .map(|(version, keys)| (version.min(4), keys))
319                .collect::<Vec<_>>();
320            lookups.sort();
321            assert!(
322                (result, lookups)
323                    == (
324                        maplit::btreemap! {
325                            "a".to_owned() => Ok(()),
326                            "b".to_owned() => Ok(()),
327                            "c".to_owned() => Ok(()),
328                        },
329                        expected,
330                    ),
331                "case {name}"
332            );
333        }
334    }
335
336    /// A broker below `FindCoordinator` v4 cannot look up many keys at once,
337    /// so each group gets its own lookup, as Kafka's `CoordinatorStrategy`
338    /// does after `disableBatch`. The groups of one coordinator still share
339    /// one `DeleteGroups` request.
340    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
341    async fn delete_groups_looks_up_one_group_at_a_time_below_find_coordinator_v4() {
342        let cluster = delete_groups_cluster(3).await;
343        let admin = cluster.admin(krabka_units::secs(5)).await;
344
345        let result = admin.delete_consumer_groups(&["a", "b"]).await;
346
347        let seen = cluster.stop();
348        assert!(
349            (result, seen.find_coordinator, seen.requests.len())
350                == (
351                    maplit::btreemap! {
352                        "a".to_owned() => Ok(()),
353                        "b".to_owned() => Ok(()),
354                    },
355                    vec![(3, names(&["a"])), (3, names(&["b"]))],
356                    1,
357                )
358        );
359    }
360}