Skip to main content

krabka_client_admin/
group_descriptions.rs

1//! Batched group descriptions: [`AdminClient::describe_consumer_groups`],
2//! [`AdminClient::describe_classic_groups`],
3//! [`AdminClient::describe_share_groups`] and
4//! [`AdminClient::describe_streams_groups`].
5//!
6//! Each call finds the coordinator of every group with one batched
7//! `FindCoordinator` request and sends one describe request to each
8//! coordinator, as Apache Kafka's `Describe*GroupsHandler` classes do with
9//! their `CoordinatorStrategy`. A request to a coordinator that
10//! fails or loses its connection finds the coordinator again.
11
12use std::{
13    collections::{BTreeMap, BTreeSet},
14    sync::{Mutex, PoisonError},
15};
16
17use bytes::{Buf, BufMut};
18use krabka_client_core::{ClientError, CoordinatorEndpoint};
19use krabka_protocol::{
20    Decode, Encode, ProtocolError, ProtocolRequest,
21    owned::{
22        common::consumer_group_describe_response::assignment::Assignment,
23        consumer_group_describe_request::{self, ConsumerGroupDescribeRequest},
24        consumer_group_describe_response::{
25            DescribedGroup as ConsumerDescribedGroup, Member as ConsumerMember,
26        },
27        consumer_protocol_assignment::{self, ConsumerProtocolAssignment},
28        describe_groups_request::DescribeGroupsRequest,
29        describe_groups_response::{DescribedGroup as ClassicDescribedGroup, DescribedGroupMember},
30        share_group_describe_request::ShareGroupDescribeRequest,
31        share_group_describe_response::DescribedGroup as ShareDescribedGroup,
32        streams_group_describe_request::{self, StreamsGroupDescribeRequest},
33        streams_group_describe_response::{
34            DescribedGroup as StreamsDescribedGroup, StreamsGroupDescribeResponse,
35        },
36    },
37};
38
39use crate::{
40    AdminClient, AdminError, ClusterNode, KafkaError,
41    cluster::{authorized_operations, node_with_host_port},
42    group_coordinators::{GroupOutcome, group_error_outcome, kafka_error},
43    groups::{GroupState, GroupType, list_groups_kafka_error},
44    kafka_error_name,
45    retry::is_connection_failure,
46    users::AclOperation,
47};
48
49/// `UNKNOWN_SERVER_ERROR`: the code that Kafka's `ApiError.fromThrowable`
50/// gives an exception with no Kafka error code.
51const UNKNOWN_SERVER_ERROR: i16 = -1;
52/// `UNSUPPORTED_VERSION`.
53const UNSUPPORTED_VERSION: i16 = 35;
54/// `GROUP_ID_NOT_FOUND`.
55const GROUP_ID_NOT_FOUND: i16 = 69;
56/// The protocol type of a classic consumer group
57/// (`ConsumerProtocol.PROTOCOL_TYPE`).
58const CONSUMER_PROTOCOL_TYPE: &str = "consumer";
59
60/// A classic group's description from `DescribeGroups`, as Apache Kafka's
61/// `ClassicGroupDescription` holds it. This is the broker's wire response
62/// entry for the group.
63pub type ClassicGroupDescription = ClassicDescribedGroup;
64
65/// A share group's description from `ShareGroupDescribe`, as Apache Kafka's
66/// `ShareGroupDescription` holds it. This is the broker's wire response entry
67/// for the group.
68pub type ShareGroupDescription = ShareDescribedGroup;
69
70/// A streams group's description from `StreamsGroupDescribe`, as Apache
71/// Kafka's `StreamsGroupDescription` holds it. This is the broker's wire
72/// response entry for the group.
73pub type StreamsGroupDescription = StreamsDescribedGroup;
74
75/// The options of the describe calls, as Apache Kafka's
76/// `Describe*GroupsOptions` hold them.
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, bon::Builder)]
78pub struct DescribeGroupsOptions {
79    /// Ask for the operations that the caller may perform on each group.
80    /// Kafka's default is `false`.
81    #[builder(default)]
82    pub include_authorized_operations: bool,
83}
84
85/// One member of a consumer group, as Apache Kafka's `MemberDescription`
86/// holds it.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct MemberDescription {
89    pub member_id: String,
90    pub group_instance_id: Option<String>,
91    /// `None` for a member of a classic group.
92    pub rack_id: Option<String>,
93    pub client_id: String,
94    pub host: String,
95    /// The partitions that the member owns now.
96    pub assignment: BTreeSet<(String, i32)>,
97    /// The partitions that the coordinator wants the member to own. `None`
98    /// for a member of a classic group.
99    pub target_assignment: Option<BTreeSet<(String, i32)>>,
100    /// `None` for a member of a classic group.
101    pub member_epoch: Option<i32>,
102    /// Whether the member uses the consumer protocol (KIP-1099). `None` when
103    /// the coordinator does not say (`ConsumerGroupDescribe` v0) or for a
104    /// member of a classic group.
105    pub upgraded: Option<bool>,
106}
107
108/// A consumer group, classic or KIP-848, as Apache Kafka's
109/// `ConsumerGroupDescription` holds it.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ConsumerGroupDescription {
112    pub group_id: String,
113    /// A classic group with no protocol type.
114    pub is_simple_consumer_group: bool,
115    pub members: Vec<MemberDescription>,
116    /// The server assignor of a consumer group, or the protocol of a classic
117    /// group.
118    pub partition_assignor: String,
119    /// [`GroupType::Consumer`] or [`GroupType::Classic`].
120    pub group_type: GroupType,
121    pub group_state: GroupState,
122    /// The coordinator that `FindCoordinator` named.
123    pub coordinator: ClusterNode,
124    /// `None` when the caller did not ask for them.
125    pub authorized_operations: Option<BTreeSet<AclOperation>>,
126    /// `None` for a classic group.
127    pub group_epoch: Option<i32>,
128    /// `None` for a classic group.
129    pub target_assignment_epoch: Option<i32>,
130}
131
132/// The per-group results of a describe call: one entry for each requested
133/// group.
134pub type GroupDescriptions<T> = BTreeMap<String, Result<T, KafkaError>>;
135
136/// A `StreamsGroupDescribe` request limited to v0.
137///
138/// Apache Kafka 4.3.1 defines `StreamsGroupDescribe` v0 only. v1
139/// (`IncludeTopologyDescription`, KIP-1331) is newer than that release, so
140/// this type keeps version negotiation at v0.
141#[derive(Clone, Debug, PartialEq, Eq)]
142struct ReleasedStreamsGroupDescribe(StreamsGroupDescribeRequest);
143
144impl Encode for ReleasedStreamsGroupDescribe {
145    fn encode<B: BufMut>(&self, buf: &mut B, version: i16) -> Result<(), ProtocolError> {
146        self.0.encode(buf, version)
147    }
148
149    fn encoded_len(&self, version: i16) -> usize {
150        self.0.encoded_len(version)
151    }
152}
153
154impl ProtocolRequest for ReleasedStreamsGroupDescribe {
155    const API_KEY: i16 = streams_group_describe_request::API_KEY;
156    const MIN_VERSION: i16 = streams_group_describe_request::MIN_VERSION;
157    /// The last `StreamsGroupDescribe` version of Kafka 4.3.1.
158    const MAX_VERSION: i16 = 0;
159    const LATEST_STABLE_VERSION: i16 = Self::MAX_VERSION;
160    const FLEXIBLE_MIN: i16 = streams_group_describe_request::FLEXIBLE_MIN;
161    type Response = StreamsGroupDescribeResponse;
162}
163
164trait GroupDescribeRequest: ProtocolRequest {
165    fn for_groups(groups: Vec<String>, options: DescribeGroupsOptions) -> Self;
166}
167
168macro_rules! impl_group_describe_request {
169    ($request:ty, $groups_field:ident) => {
170        impl GroupDescribeRequest for $request {
171            fn for_groups(groups: Vec<String>, options: DescribeGroupsOptions) -> Self {
172                Self {
173                    $groups_field: groups,
174                    include_authorized_operations: options.include_authorized_operations,
175                    ..Default::default()
176                }
177            }
178        }
179    };
180}
181
182impl_group_describe_request!(ConsumerGroupDescribeRequest, group_ids);
183impl_group_describe_request!(DescribeGroupsRequest, groups);
184impl_group_describe_request!(ShareGroupDescribeRequest, group_ids);
185
186impl GroupDescribeRequest for ReleasedStreamsGroupDescribe {
187    fn for_groups(groups: Vec<String>, options: DescribeGroupsOptions) -> Self {
188        Self(StreamsGroupDescribeRequest {
189            group_ids: groups,
190            include_authorized_operations: options.include_authorized_operations,
191            ..Default::default()
192        })
193    }
194}
195
196trait GroupDescriptionEntry: Sized {
197    fn group_id(&self) -> &str;
198    fn error_code(&self) -> i16;
199    fn error_message(self) -> Option<String>;
200    fn successful_outcome(self) -> GroupOutcome<Self>;
201
202    fn into_outcome(self) -> (String, GroupOutcome<Self>) {
203        let group_id = self.group_id().to_owned();
204        let error_code = self.error_code();
205        let outcome = if error_code == 0 {
206            self.successful_outcome()
207        } else {
208            group_error_outcome(error_code, self.error_message(), &[])
209        };
210        (group_id, outcome)
211    }
212}
213
214macro_rules! impl_group_description_entry {
215    ($entry:ty, $error_message:expr, $successful_outcome:expr) => {
216        impl GroupDescriptionEntry for $entry {
217            fn group_id(&self) -> &str {
218                &self.group_id
219            }
220
221            fn error_message(self) -> Option<String> {
222                ($error_message)(self)
223            }
224
225            fn successful_outcome(self) -> GroupOutcome<Self> {
226                ($successful_outcome)(self)
227            }
228
229            fn error_code(&self) -> i16 {
230                self.error_code
231            }
232        }
233    };
234}
235
236impl_group_description_entry!(
237    ClassicDescribedGroup,
238    |_: ClassicDescribedGroup| None,
239    |group: ClassicDescribedGroup| GroupOutcome::Done(Ok(group))
240);
241impl_group_description_entry!(
242    ShareDescribedGroup,
243    |group: ShareDescribedGroup| group.error_message,
244    |group: ShareDescribedGroup| GroupOutcome::Done(Ok(group))
245);
246impl_group_description_entry!(
247    StreamsDescribedGroup,
248    |group: StreamsDescribedGroup| group.error_message,
249    |group: StreamsDescribedGroup| {
250        if group.topology.is_none() {
251            GroupOutcome::Done(Err(KafkaError {
252                code: UNKNOWN_SERVER_ERROR,
253                name: kafka_error_name(UNKNOWN_SERVER_ERROR),
254                message: Some("Topology information is missing".to_owned()),
255            }))
256        } else {
257            GroupOutcome::Done(Ok(group))
258        }
259    }
260);
261
262trait GroupDescribeApi: GroupDescribeRequest {
263    type Description;
264
265    const METHOD_NAME: &'static str;
266
267    fn outcomes(response: Self::Response) -> BTreeMap<String, GroupOutcome<Self::Description>>;
268}
269
270macro_rules! impl_group_describe_api {
271    ($request:ty, $description:ty, $method_name:literal) => {
272        impl GroupDescribeApi for $request {
273            type Description = $description;
274
275            const METHOD_NAME: &'static str = $method_name;
276
277            fn outcomes(
278                response: Self::Response,
279            ) -> BTreeMap<String, GroupOutcome<Self::Description>> {
280                group_description_outcomes(response.groups)
281            }
282        }
283    };
284}
285
286impl_group_describe_api!(
287    DescribeGroupsRequest,
288    ClassicGroupDescription,
289    "describeClassicGroups"
290);
291impl_group_describe_api!(
292    ShareGroupDescribeRequest,
293    ShareGroupDescription,
294    "describeShareGroups"
295);
296impl_group_describe_api!(
297    ReleasedStreamsGroupDescribe,
298    StreamsGroupDescription,
299    "describeStreamsGroups"
300);
301
302fn group_description_outcomes<Group: GroupDescriptionEntry>(
303    groups: impl IntoIterator<Item = Group>,
304) -> BTreeMap<String, GroupOutcome<Group>> {
305    groups
306        .into_iter()
307        .map(GroupDescriptionEntry::into_outcome)
308        .collect()
309}
310
311fn insert_description_outcomes<Group, Description>(
312    outcomes: &mut BTreeMap<String, GroupOutcome<Description>>,
313    groups: impl IntoIterator<Item = Group>,
314    mut classify: impl FnMut(Group) -> (String, Option<GroupOutcome<Description>>),
315) {
316    for group in groups {
317        let (group_id, outcome) = classify(group);
318        if let Some(outcome) = outcome {
319            outcomes.insert(group_id, outcome);
320        }
321    }
322}
323
324fn consumer_description_outcome(
325    group: ConsumerDescribedGroup,
326    node: &ClusterNode,
327    classic_keys: &mut Vec<String>,
328    classic: &Mutex<BTreeMap<String, Option<String>>>,
329) -> (String, Option<GroupOutcome<ConsumerGroupDescription>>) {
330    let group_id = group.group_id.clone();
331    let outcome = match consumer_answer(group, node) {
332        ConsumerAnswer::Outcome(outcome) => Some(outcome),
333        ConsumerAnswer::Classic { not_found_message } => {
334            classic
335                .lock()
336                .unwrap_or_else(PoisonError::into_inner)
337                .insert(group_id.clone(), not_found_message);
338            classic_keys.push(group_id.clone());
339            None
340        }
341    };
342    (group_id, outcome)
343}
344
345fn classic_consumer_description_outcome(
346    group: ClassicDescribedGroup,
347    node: &ClusterNode,
348    classic: &Mutex<BTreeMap<String, Option<String>>>,
349) -> (String, Option<GroupOutcome<ConsumerGroupDescription>>) {
350    let group_id = group.group_id.clone();
351    let not_found_message = classic
352        .lock()
353        .unwrap_or_else(PoisonError::into_inner)
354        .get(&group_id)
355        .cloned()
356        .flatten();
357    (
358        group_id,
359        Some(classic_consumer_outcome(group, node, not_found_message)),
360    )
361}
362
363/// The node that `FindCoordinator` named, as Kafka's `Node` holds it.
364fn coordinator_node(coordinator: &CoordinatorEndpoint) -> ClusterNode {
365    node_with_host_port(
366        coordinator.node_id,
367        coordinator.host.clone(),
368        coordinator.port,
369    )
370}
371
372/// The outcome of every group of `keys` after a request to their coordinator
373/// failed with `error`: a failed or lost connection finds the coordinator
374/// again, as Kafka's `AdminApiDriver.onFailure` does after a disconnect, and
375/// every other failure is final.
376pub(crate) fn request_error_outcomes<V>(
377    keys: &[String],
378    error: &AdminError,
379) -> BTreeMap<String, GroupOutcome<V>> {
380    keys.iter()
381        .map(|key| {
382            let outcome = if is_connection_failure(error) {
383                GroupOutcome::Unmap(error.to_string())
384            } else {
385                GroupOutcome::Done(Err(list_groups_kafka_error(error)))
386            };
387            (key.clone(), outcome)
388        })
389        .collect()
390}
391
392/// The partitions of a `ConsumerGroupDescribe` assignment.
393fn consumer_assignment(assignment: Assignment) -> BTreeSet<(String, i32)> {
394    assignment_partitions(
395        assignment
396            .topic_partitions
397            .into_iter()
398            .map(|topic| (topic.topic_name, topic.partitions)),
399    )
400}
401
402fn assignment_partitions(
403    topics: impl IntoIterator<Item = (String, Vec<i32>)>,
404) -> BTreeSet<(String, i32)> {
405    topics
406        .into_iter()
407        .fold(BTreeSet::new(), |mut result, (name, partitions)| {
408            result.extend(
409                partitions
410                    .into_iter()
411                    .map(|partition| (name.clone(), partition)),
412            );
413            result
414        })
415}
416
417/// A member of a `ConsumerGroupDescribe` answer, as Kafka's
418/// `DescribeConsumerGroupsHandler.handledConsumerGroupResponse` reads it.
419fn consumer_member(member: ConsumerMember) -> MemberDescription {
420    MemberDescription {
421        member_id: member.member_id,
422        group_instance_id: member.instance_id,
423        rack_id: member.rack_id,
424        client_id: member.client_id,
425        host: member.client_host,
426        assignment: consumer_assignment(member.assignment),
427        target_assignment: Some(consumer_assignment(member.target_assignment)),
428        member_epoch: Some(member.member_epoch),
429        upgraded: (member.member_type != -1).then_some(member.member_type == 1),
430    }
431}
432
433/// Reads a version-prefixed `ConsumerProtocolAssignment`, as Kafka's
434/// `ConsumerProtocol.deserializeAssignment` does: a version above the
435/// highest known version reads as the highest known version, and a negative
436/// version is an error.
437fn classic_assignment(mut bytes: &[u8]) -> Result<BTreeSet<(String, i32)>, String> {
438    if bytes.is_empty() {
439        return Ok(BTreeSet::new());
440    }
441    if bytes.len() < 2 {
442        return Err("Buffer underflow while parsing consumer protocol's header".to_owned());
443    }
444    let version = bytes.get_i16();
445    if version < consumer_protocol_assignment::MIN_VERSION {
446        return Err(format!("Unsupported assignment version: {version}"));
447    }
448    let version = version.min(consumer_protocol_assignment::MAX_VERSION);
449    let assignment = ConsumerProtocolAssignment::decode(&mut bytes, version)
450        .map_err(|error| format!("Error reading consumer assignment: {error}"))?;
451    Ok(assignment_partitions(
452        assignment
453            .assigned_partitions
454            .into_iter()
455            .map(|topic| (topic.topic, topic.partitions)),
456    ))
457}
458
459/// A member of a classic `DescribeGroups` answer, as Kafka's
460/// `DescribeConsumerGroupsHandler.handledClassicGroupResponse` reads it.
461fn classic_member(member: DescribedGroupMember) -> Result<MemberDescription, String> {
462    Ok(MemberDescription {
463        assignment: classic_assignment(&member.member_assignment)?,
464        member_id: member.member_id,
465        group_instance_id: member.group_instance_id,
466        rack_id: None,
467        client_id: member.client_id,
468        host: member.client_host,
469        target_assignment: None,
470        member_epoch: None,
471        upgraded: None,
472    })
473}
474
475/// What the answer of `ConsumerGroupDescribe` means for one group.
476#[derive(Debug, Clone, PartialEq, Eq)]
477enum ConsumerAnswer {
478    Outcome(GroupOutcome<ConsumerGroupDescription>),
479    /// Describe the group with `DescribeGroups`, as Kafka's
480    /// `useClassicGroupApi` set records it. The message of a
481    /// `GROUP_ID_NOT_FOUND` answer, if any, is kept for the classic answer.
482    Classic {
483        not_found_message: Option<String>,
484    },
485}
486
487/// Classifies one `ConsumerGroupDescribe` entry, as Kafka's
488/// `DescribeConsumerGroupsHandler.handleError` does with
489/// `isConsumerGroupResponse` set: `UNSUPPORTED_VERSION` and
490/// `GROUP_ID_NOT_FOUND` move the group to `DescribeGroups`.
491fn consumer_answer(group: ConsumerDescribedGroup, coordinator: &ClusterNode) -> ConsumerAnswer {
492    match group.error_code {
493        0 => ConsumerAnswer::Outcome(GroupOutcome::Done(Ok(ConsumerGroupDescription {
494            group_id: group.group_id,
495            is_simple_consumer_group: false,
496            members: group.members.into_iter().map(consumer_member).collect(),
497            partition_assignor: group.assignor_name,
498            group_type: GroupType::Consumer,
499            group_state: GroupState::parse(&group.group_state),
500            coordinator: coordinator.clone(),
501            authorized_operations: authorized_operations(group.authorized_operations),
502            group_epoch: Some(group.group_epoch),
503            target_assignment_epoch: Some(group.assignment_epoch),
504        }))),
505        UNSUPPORTED_VERSION => ConsumerAnswer::Classic {
506            not_found_message: None,
507        },
508        GROUP_ID_NOT_FOUND => ConsumerAnswer::Classic {
509            not_found_message: group.error_message,
510        },
511        code => ConsumerAnswer::Outcome(group_error_outcome(code, group.error_message, &[])),
512    }
513}
514
515/// Classifies one `DescribeGroups` entry of `describe_consumer_groups`, as
516/// Kafka's `DescribeConsumerGroupsHandler` does for a group in
517/// `useClassicGroupApi`. A group whose protocol type is neither `consumer`
518/// nor empty is not a consumer group. `GROUP_ID_NOT_FOUND` carries the
519/// message of the `ConsumerGroupDescribe` answer when there was one.
520fn classic_consumer_outcome(
521    group: ClassicDescribedGroup,
522    coordinator: &ClusterNode,
523    not_found_message: Option<String>,
524) -> GroupOutcome<ConsumerGroupDescription> {
525    match group.error_code {
526        0 => {}
527        GROUP_ID_NOT_FOUND => {
528            return GroupOutcome::Done(Err(kafka_error(
529                GROUP_ID_NOT_FOUND,
530                not_found_message.or(group.error_message),
531            )));
532        }
533        code => return group_error_outcome(code, group.error_message, &[]),
534    }
535    if group.protocol_type != CONSUMER_PROTOCOL_TYPE && !group.protocol_type.is_empty() {
536        return GroupOutcome::Done(Err(kafka_error(
537            UNKNOWN_SERVER_ERROR,
538            Some(format!(
539                "GroupId {} is not a consumer group ({}).",
540                group.group_id, group.protocol_type
541            )),
542        )));
543    }
544    let members = match group
545        .members
546        .into_iter()
547        .map(classic_member)
548        .collect::<Result<Vec<_>, _>>()
549    {
550        Ok(members) => members,
551        Err(message) => {
552            return GroupOutcome::Done(Err(kafka_error(UNKNOWN_SERVER_ERROR, Some(message))));
553        }
554    };
555    GroupOutcome::Done(Ok(ConsumerGroupDescription {
556        is_simple_consumer_group: group.protocol_type.is_empty(),
557        group_id: group.group_id,
558        members,
559        partition_assignor: group.protocol_data,
560        group_type: GroupType::Classic,
561        group_state: GroupState::parse(&group.group_state),
562        coordinator: coordinator.clone(),
563        authorized_operations: authorized_operations(group.authorized_operations),
564        group_epoch: None,
565        target_assignment_epoch: None,
566    }))
567}
568
569impl AdminClient {
570    /// Describes consumer groups, classic and KIP-848, as Apache Kafka's
571    /// `describeConsumerGroups` does with its `DescribeConsumerGroupsHandler`.
572    ///
573    /// The call finds the coordinator of every group with one batched
574    /// `FindCoordinator` request, and sends one `ConsumerGroupDescribe`
575    /// request to each coordinator with the groups that it coordinates. A
576    /// group that the answer gives `GROUP_ID_NOT_FOUND` (69) or
577    /// `UNSUPPORTED_VERSION` (35), and every group of a coordinator that does
578    /// not support `ConsumerGroupDescribe`, is described with `DescribeGroups`
579    /// instead, as Kafka's handler falls back for a classic group. Kafka
580    /// sends that `DescribeGroups` request in the next round of its driver.
581    /// This call sends it in the same round, to the same coordinator.
582    ///
583    /// `COORDINATOR_LOAD_IN_PROGRESS` (14) sends a group to the same
584    /// coordinator again, and `COORDINATOR_NOT_AVAILABLE` (15) and
585    /// `NOT_COORDINATOR` (16) find its coordinator again. A lost
586    /// connection finds the coordinator again. The call waits with Kafka's
587    /// backoff between rounds and stops at `default.api.timeout.ms` (60 s),
588    /// where each pending group gets `REQUEST_TIMED_OUT` (7).
589    ///
590    /// # Errors
591    /// The call itself does not fail. Each group gets its own [`KafkaError`]:
592    /// the error code of the answer, `GROUP_ID_NOT_FOUND` with the message of
593    /// the `ConsumerGroupDescribe` answer for a group that does not exist,
594    /// `UNKNOWN_SERVER_ERROR` (-1) for a classic group that is not a consumer
595    /// group, or `REQUEST_TIMED_OUT` (7) at the deadline.
596    pub async fn describe_consumer_groups(
597        &self,
598        groups: &[&str],
599        options: DescribeGroupsOptions,
600    ) -> GroupDescriptions<ConsumerGroupDescription> {
601        let classic = Mutex::new(BTreeMap::<String, Option<String>>::new());
602        self.call_group_coordinators("describeConsumerGroups", groups, |coordinator, keys| {
603            self.describe_consumer_groups_on(coordinator, keys, options, &classic)
604        })
605        .await
606    }
607
608    /// One `describe_consumer_groups` request to one coordinator. `classic`
609    /// holds the groups that use `DescribeGroups`, with the message of their
610    /// `GROUP_ID_NOT_FOUND` answer.
611    async fn describe_consumer_groups_on(
612        &self,
613        coordinator: CoordinatorEndpoint,
614        keys: Vec<String>,
615        options: DescribeGroupsOptions,
616        classic: &Mutex<BTreeMap<String, Option<String>>>,
617    ) -> Result<BTreeMap<String, GroupOutcome<ConsumerGroupDescription>>, AdminError> {
618        let connection = self.connect_coordinator(&coordinator).await?;
619        let node = coordinator_node(&coordinator);
620        let (classic_keys, consumer_keys): (Vec<_>, Vec<_>) = {
621            let classic = classic.lock().unwrap_or_else(PoisonError::into_inner);
622            keys.into_iter().partition(|key| classic.contains_key(key))
623        };
624        let mut outcomes = BTreeMap::new();
625        let mut classic_keys = classic_keys;
626        // Kafka's `NodeApiVersions` fails a request for an API that the
627        // coordinator does not advertise with an `UnsupportedVersionException`,
628        // and `handleUnsupportedVersionException` moves each group to the
629        // classic API.
630        let consumer_describe_supported = connection
631            .advertised_api_range(consumer_group_describe_request::API_KEY)
632            .is_some();
633        if !consumer_describe_supported {
634            let mut classic = classic.lock().unwrap_or_else(PoisonError::into_inner);
635            for key in &consumer_keys {
636                classic.entry(key.clone()).or_insert(None);
637            }
638            drop(classic);
639            classic_keys.extend(consumer_keys.iter().cloned());
640        } else if !consumer_keys.is_empty() {
641            let request = ConsumerGroupDescribeRequest::for_groups(consumer_keys.clone(), options);
642            match connection.send(request).await {
643                Ok(response) => {
644                    insert_description_outcomes(&mut outcomes, response.groups, |group| {
645                        consumer_description_outcome(group, &node, &mut classic_keys, classic)
646                    });
647                }
648                // Kafka's `handleUnsupportedVersionException` moves each group
649                // to the classic API.
650                Err(ClientError::IncompatibleVersion { .. }) => {
651                    let mut classic = classic.lock().unwrap_or_else(PoisonError::into_inner);
652                    for key in &consumer_keys {
653                        classic.entry(key.clone()).or_insert(None);
654                    }
655                    drop(classic);
656                    classic_keys.extend(consumer_keys);
657                }
658                Err(error) => {
659                    let error = AdminError::from(error);
660                    if is_connection_failure(&error) {
661                        return Err(error);
662                    }
663                    outcomes.extend(request_error_outcomes(&consumer_keys, &error));
664                }
665            }
666        }
667        if classic_keys.is_empty() {
668            return Ok(outcomes);
669        }
670        let request = DescribeGroupsRequest::for_groups(classic_keys.clone(), options);
671        match connection.send(request).await {
672            Ok(response) => {
673                insert_description_outcomes(&mut outcomes, response.groups, |group| {
674                    classic_consumer_description_outcome(group, &node, classic)
675                });
676            }
677            Err(error) => {
678                outcomes.extend(request_error_outcomes(&classic_keys, &error.into()));
679            }
680        }
681        Ok(outcomes)
682    }
683
684    /// Describes classic groups with `DescribeGroups`, as Apache Kafka's
685    /// `describeClassicGroups` does with its `DescribeClassicGroupsHandler`.
686    ///
687    /// The call finds the coordinator of every group with one batched
688    /// `FindCoordinator` request and sends one `DescribeGroups` request to
689    /// each coordinator. The coordinator codes retry as
690    /// [`AdminClient::describe_consumer_groups`] describes. Every other code
691    /// is the final error of its group, with no message, as the handler uses
692    /// `error.message()`.
693    ///
694    /// # Errors
695    /// The call itself does not fail. Each group gets its own [`KafkaError`].
696    pub async fn describe_classic_groups(
697        &self,
698        groups: &[&str],
699        options: DescribeGroupsOptions,
700    ) -> GroupDescriptions<ClassicGroupDescription> {
701        self.describe_group_api::<DescribeGroupsRequest>(groups, options)
702            .await
703    }
704
705    /// Describes share groups with `ShareGroupDescribe`, as Apache Kafka's
706    /// `describeShareGroups` does with its `DescribeShareGroupsHandler`.
707    ///
708    /// The call finds the coordinator of every group with one batched
709    /// `FindCoordinator` request and sends one `ShareGroupDescribe` request
710    /// to each coordinator. The coordinator codes retry as
711    /// [`AdminClient::describe_consumer_groups`] describes. Every other code,
712    /// such as `GROUP_ID_NOT_FOUND`, is the final error of its group, with
713    /// the message of the answer.
714    ///
715    /// # Errors
716    /// The call itself does not fail. Each group gets its own [`KafkaError`].
717    pub async fn describe_share_groups(
718        &self,
719        groups: &[&str],
720        options: DescribeGroupsOptions,
721    ) -> GroupDescriptions<ShareGroupDescription> {
722        self.describe_group_api::<ShareGroupDescribeRequest>(groups, options)
723            .await
724    }
725
726    /// Describes streams groups with `StreamsGroupDescribe` v0, as Apache
727    /// Kafka's `describeStreamsGroups` does with its
728    /// `DescribeStreamsGroupsHandler`.
729    ///
730    /// The call finds the coordinator of every group with one batched
731    /// `FindCoordinator` request and sends one `StreamsGroupDescribe` request
732    /// to each coordinator. The coordinator codes retry as
733    /// [`AdminClient::describe_consumer_groups`] describes. Every other code
734    /// is the final error of its group, with the message of the answer. A
735    /// group whose answer has no topology gets `UNKNOWN_SERVER_ERROR` (-1),
736    /// as the handler fails it with an `IllegalStateException`.
737    ///
738    /// # Errors
739    /// The call itself does not fail. Each group gets its own [`KafkaError`].
740    pub async fn describe_streams_groups(
741        &self,
742        groups: &[&str],
743        options: DescribeGroupsOptions,
744    ) -> GroupDescriptions<StreamsGroupDescription> {
745        self.describe_group_api::<ReleasedStreamsGroupDescribe>(groups, options)
746            .await
747    }
748
749    async fn describe_group_api<Request: GroupDescribeApi>(
750        &self,
751        groups: &[&str],
752        options: DescribeGroupsOptions,
753    ) -> GroupDescriptions<Request::Description> {
754        self.call_group_coordinators(
755            Request::METHOD_NAME,
756            groups,
757            |coordinator, keys| async move {
758                let connection = self.connect_coordinator(&coordinator).await?;
759                let request = Request::for_groups(keys.clone(), options);
760                Ok(match connection.send(request).await {
761                    Ok(response) => Request::outcomes(response),
762                    Err(error) => request_error_outcomes(&keys, &error.into()),
763                })
764            },
765        )
766        .await
767    }
768}
769
770/// Classifies one `StreamsGroupDescribe` entry, as Kafka's
771/// `DescribeStreamsGroupsHandler.handleResponse` does.
772#[cfg(test)]
773mod tests {
774    use std::{
775        net::SocketAddr,
776        sync::{Arc, Mutex},
777    };
778
779    use assert2::assert;
780    use bytes::BytesMut;
781    use krabka_client_core::MockReply;
782    use krabka_protocol::owned::{
783        common::consumer_group_describe_response::topic_partitions::TopicPartitions,
784        consumer_group_describe_response::ConsumerGroupDescribeResponse,
785        consumer_protocol_assignment::TopicPartition as AssignedTopic, describe_groups_request,
786        describe_groups_response::DescribeGroupsResponse, share_group_describe_request,
787        share_group_describe_response::ShareGroupDescribeResponse,
788        streams_group_describe_response::Topology,
789    };
790
791    use super::*;
792    use crate::{
793        group_coordinators::test_support::{GroupCluster, Lookup, Seen, group_cluster},
794        partition_leaders::test_support::{ApiVersionRange, decode_request, encode_response},
795    };
796
797    fn api_range(api_key: i16, max_version: i16) -> ApiVersionRange {
798        ApiVersionRange::builder()
799            .api_key(api_key)
800            .max_version(max_version)
801            .build()
802    }
803
804    fn mock_response(bytes: Vec<u8>) -> MockReply {
805        MockReply::Respond(bytes)
806    }
807
808    fn classic_response(groups: Vec<ClassicDescribedGroup>) -> DescribeGroupsResponse {
809        DescribeGroupsResponse {
810            groups,
811            ..DescribeGroupsResponse::default()
812        }
813    }
814
815    const FALLBACK_GROUPS: [&str; 5] = ["kip848", "classic", "other", "missing", "connect"];
816    const STREAMS_GROUPS: [&str; 2] = ["with", "without"];
817
818    struct AdminGroupCluster {
819        cluster: GroupCluster,
820        admin: AdminClient,
821    }
822
823    macro_rules! described_group {
824        ($entry:ident, $error_code:expr, $error_message:expr $(, $field:ident = $value:expr)* $(,)?) => {
825            $entry {
826                error_code: $error_code,
827                error_message: $error_message,
828                group_id: "workers".to_owned(),
829                $($field: $value,)*
830                ..Default::default()
831            }
832        };
833    }
834
835    macro_rules! encode_described_group {
836        ($response:ident, $entry:ident, $code:expr, $message:expr, $version:expr, $flexible:expr $(, $field:ident = $value:expr)* $(,)?) => {
837            crate::encode_test_struct_field!(
838                encode_response,
839                $response,
840                groups,
841                vec![described_group!($entry, $code, $message $(, $field = $value)*)],
842                $version,
843                $flexible,
844            )
845        };
846    }
847
848    #[bon::builder]
849    async fn admin_group_cluster(
850        apis: Vec<ApiVersionRange>,
851        answer: impl FnMut(usize, i16, i16, &[u8], SocketAddr) -> MockReply + Send + 'static,
852    ) -> AdminGroupCluster {
853        let cluster = group_cluster()
854            .count(1)
855            .apis(apis)
856            .lookup(|_, _| Lookup::At(0))
857            .answer(answer)
858            .call()
859            .await;
860        let admin = cluster.admin(krabka_units::secs(5)).await;
861        AdminGroupCluster { cluster, admin }
862    }
863
864    const OMITTED: i32 = i32::MIN;
865
866    fn assignment_bytes(version: i16, partitions: &[(&str, &[i32])]) -> bytes::Bytes {
867        let mut bytes = BytesMut::new();
868        bytes.put_i16(version);
869        ConsumerProtocolAssignment {
870            assigned_partitions: partitions
871                .iter()
872                .map(|(topic, partitions)| AssignedTopic {
873                    topic: (*topic).to_owned(),
874                    partitions: partitions.to_vec(),
875                    ..Default::default()
876                })
877                .collect(),
878            ..Default::default()
879        }
880        .encode(&mut bytes, version.min(3))
881        .expect("assignment encodes");
882        bytes.freeze()
883    }
884
885    #[yare::parameterized(
886        empty = { bytes::Bytes::new(), Ok(BTreeSet::new()) },
887        v0 = { assignment_bytes(0, &[("orders", &[0, 2])]), Ok(maplit::btreeset! {
888            ("orders".to_owned(), 0),
889            ("orders".to_owned(), 2),
890        }) },
891        newer_version = { assignment_bytes(9, &[("orders", &[1])]), Ok(maplit::btreeset! {
892            ("orders".to_owned(), 1),
893        }) },
894        negative_version = { bytes::Bytes::from_static(&[0xff, 0xff]), Err("Unsupported assignment version: -1".to_owned()) },
895        truncated_header = { bytes::Bytes::from_static(&[0]), Err("Buffer underflow while parsing consumer protocol's header".to_owned()) },
896    )]
897    fn classic_assignment_reads_as_kafka_does(
898        bytes: bytes::Bytes,
899        expected: Result<BTreeSet<(String, i32)>, String>,
900    ) {
901        assert2::assert!(classic_assignment(&bytes) == expected);
902    }
903
904    fn node(addr: SocketAddr, id: i32) -> ClusterNode {
905        node_with_host_port(id, addr.ip().to_string(), i32::from(addr.port()))
906    }
907
908    fn nodes(addrs: [SocketAddr; 2]) -> [ClusterNode; 2] {
909        [node(addrs[0], 0), node(addrs[1], 1)]
910    }
911
912    fn consumer_entry(
913        group_id: &str,
914        error_code: i16,
915        message: Option<&str>,
916    ) -> ConsumerDescribedGroup {
917        ConsumerDescribedGroup {
918            error_code,
919            error_message: message.map(str::to_owned),
920            group_id: group_id.to_owned(),
921            group_state: "Stable".to_owned(),
922            group_epoch: 4,
923            assignment_epoch: 3,
924            assignor_name: "uniform".to_owned(),
925            members: vec![ConsumerMember {
926                member_id: "m1".to_owned(),
927                client_id: "c1".to_owned(),
928                client_host: "/10.0.0.1".to_owned(),
929                member_epoch: 4,
930                assignment: Assignment {
931                    topic_partitions: vec![TopicPartitions {
932                        topic_name: "orders".to_owned(),
933                        partitions: vec![0],
934                        ..Default::default()
935                    }],
936                    ..Default::default()
937                },
938                member_type: 1,
939                ..Default::default()
940            }],
941            authorized_operations: OMITTED,
942            ..Default::default()
943        }
944    }
945
946    fn classic_entry(
947        group_id: &str,
948        error_code: i16,
949        protocol_type: &str,
950    ) -> ClassicDescribedGroup {
951        ClassicDescribedGroup {
952            error_code,
953            group_id: group_id.to_owned(),
954            group_state: "Stable".to_owned(),
955            protocol_type: protocol_type.to_owned(),
956            protocol_data: "range".to_owned(),
957            members: vec![DescribedGroupMember {
958                member_id: "m2".to_owned(),
959                group_instance_id: Some("static-1".to_owned()),
960                client_id: "c2".to_owned(),
961                client_host: "/10.0.0.2".to_owned(),
962                member_assignment: assignment_bytes(1, &[("orders", &[1])]),
963                ..Default::default()
964            }],
965            authorized_operations: 1 << 3,
966            ..Default::default()
967        }
968    }
969
970    struct GroupDescriptionFields {
971        member: MemberDescription,
972        partition_assignor: String,
973        group_type: GroupType,
974        authorized_operations: Option<BTreeSet<AclOperation>>,
975        group_epoch: Option<i32>,
976        target_assignment_epoch: Option<i32>,
977    }
978
979    #[bon::builder]
980    fn group_description(
981        group_id: String,
982        coordinator: ClusterNode,
983        fields: GroupDescriptionFields,
984    ) -> ConsumerGroupDescription {
985        ConsumerGroupDescription {
986            group_id,
987            is_simple_consumer_group: false,
988            members: vec![fields.member],
989            partition_assignor: fields.partition_assignor,
990            group_type: fields.group_type,
991            group_state: GroupState::Stable,
992            coordinator,
993            authorized_operations: fields.authorized_operations,
994            group_epoch: fields.group_epoch,
995            target_assignment_epoch: fields.target_assignment_epoch,
996        }
997    }
998
999    #[derive(Clone, Copy)]
1000    enum GroupDescriptionKind {
1001        Consumer,
1002        Classic,
1003    }
1004
1005    #[bon::builder]
1006    fn member_description(
1007        member_id: String,
1008        group_instance_id: Option<String>,
1009        client_id: String,
1010        host: String,
1011        assignment: BTreeSet<(String, i32)>,
1012        target_assignment: Option<BTreeSet<(String, i32)>>,
1013        member_epoch: Option<i32>,
1014        upgraded: Option<bool>,
1015    ) -> MemberDescription {
1016        MemberDescription {
1017            member_id,
1018            group_instance_id,
1019            rack_id: None,
1020            client_id,
1021            host,
1022            assignment,
1023            target_assignment,
1024            member_epoch,
1025            upgraded,
1026        }
1027    }
1028
1029    fn group_description_fields(kind: GroupDescriptionKind) -> GroupDescriptionFields {
1030        match kind {
1031            GroupDescriptionKind::Consumer => GroupDescriptionFields {
1032                member: member_description()
1033                    .member_id("m1".to_owned())
1034                    .maybe_group_instance_id(None)
1035                    .client_id("c1".to_owned())
1036                    .host("/10.0.0.1".to_owned())
1037                    .assignment(maplit::btreeset! {
1038                        ("orders".to_owned(), 0),
1039                    })
1040                    .maybe_target_assignment(Some(BTreeSet::new()))
1041                    .maybe_member_epoch(Some(4))
1042                    .maybe_upgraded(Some(true))
1043                    .call(),
1044                partition_assignor: "uniform".to_owned(),
1045                group_type: GroupType::Consumer,
1046                authorized_operations: None,
1047                group_epoch: Some(4),
1048                target_assignment_epoch: Some(3),
1049            },
1050            GroupDescriptionKind::Classic => GroupDescriptionFields {
1051                member: member_description()
1052                    .member_id("m2".to_owned())
1053                    .maybe_group_instance_id(Some("static-1".to_owned()))
1054                    .client_id("c2".to_owned())
1055                    .host("/10.0.0.2".to_owned())
1056                    .assignment(maplit::btreeset! {
1057                        ("orders".to_owned(), 1),
1058                    })
1059                    .maybe_target_assignment(None)
1060                    .maybe_member_epoch(None)
1061                    .maybe_upgraded(None)
1062                    .call(),
1063                partition_assignor: "range".to_owned(),
1064                group_type: GroupType::Classic,
1065                authorized_operations: Some(maplit::btreeset! {
1066                    AclOperation::Read,
1067                }),
1068                group_epoch: None,
1069                target_assignment_epoch: None,
1070            },
1071        }
1072    }
1073
1074    fn test_group_description(
1075        group_id: &str,
1076        coordinator: ClusterNode,
1077        kind: GroupDescriptionKind,
1078    ) -> ConsumerGroupDescription {
1079        let fields = group_description_fields(kind);
1080        group_description()
1081            .group_id(group_id.to_owned())
1082            .coordinator(coordinator)
1083            .fields(fields)
1084            .call()
1085    }
1086
1087    fn classic_group_description(coordinator: ClusterNode) -> ConsumerGroupDescription {
1088        test_group_description("classic", coordinator, GroupDescriptionKind::Classic)
1089    }
1090
1091    #[derive(Clone, Copy, Debug)]
1092    enum DescribeApiCall {
1093        Classic,
1094        Share,
1095        Streams,
1096    }
1097
1098    async fn describe_one_group(
1099        call: DescribeApiCall,
1100        admin: &AdminClient,
1101        options: DescribeGroupsOptions,
1102    ) -> Option<Result<(), KafkaError>> {
1103        match call {
1104            DescribeApiCall::Classic => admin
1105                .describe_classic_groups(&["workers"], options)
1106                .await
1107                .remove("workers")
1108                .map(|result| result.map(drop)),
1109            DescribeApiCall::Share => admin
1110                .describe_share_groups(&["workers"], options)
1111                .await
1112                .remove("workers")
1113                .map(|result| result.map(drop)),
1114            DescribeApiCall::Streams => admin
1115                .describe_streams_groups(&["workers"], options)
1116                .await
1117                .remove("workers")
1118                .map(|result| result.map(drop)),
1119        }
1120    }
1121
1122    fn default_describe_options() -> DescribeGroupsOptions {
1123        DescribeGroupsOptions::builder().build()
1124    }
1125
1126    async fn default_streams_descriptions(
1127        admin: &AdminClient,
1128        groups: &[&str],
1129    ) -> GroupDescriptions<StreamsGroupDescription> {
1130        admin
1131            .describe_streams_groups(groups, default_describe_options())
1132            .await
1133    }
1134
1135    /// The group ids of each request of `api_key` that `seen` recorded, with
1136    /// the broker index and the version, in broker order.
1137    fn requests_of<T>(
1138        seen: &Seen,
1139        api_key: i16,
1140        read: impl Fn(&[u8], i16) -> T,
1141    ) -> Vec<(usize, i16, T)> {
1142        let mut requests = seen
1143            .requests
1144            .iter()
1145            .filter(|request| request.api_key == api_key)
1146            .map(|request| {
1147                (
1148                    request.broker,
1149                    request.version,
1150                    read(&request.body, request.version),
1151                )
1152            })
1153            .collect::<Vec<_>>();
1154        // The coordinators answer at the same time, so order by broker.
1155        requests.sort_by_key(|(broker, _, _)| *broker);
1156        requests
1157    }
1158
1159    fn consumer_describe_ids(body: &[u8], version: i16) -> Vec<String> {
1160        decode_request::<ConsumerGroupDescribeRequest>(body, version, true).group_ids
1161    }
1162
1163    fn describe_groups_ids(body: &[u8], version: i16) -> Vec<String> {
1164        decode_request::<DescribeGroupsRequest>(body, version, version >= 5).groups
1165    }
1166
1167    /// Kafka's `DescribeConsumerGroupsHandler` sends one batched
1168    /// `ConsumerGroupDescribe` to each coordinator, falls back to
1169    /// `DescribeGroups` for a group that the coordinator gives
1170    /// `GROUP_ID_NOT_FOUND` or `UNSUPPORTED_VERSION`, keeps the message of
1171    /// the first `GROUP_ID_NOT_FOUND`, and fails a classic group that is not a
1172    /// consumer group.
1173    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1174    async fn describe_consumer_groups_batches_and_falls_back_to_describe_groups() {
1175        // "kip848" and "classic" live on broker 0, "other" and "missing" and
1176        // "connect" on broker 1.
1177        let cluster: GroupCluster = group_cluster()
1178            .count(2)
1179            .apis(vec![
1180                api_range(consumer_group_describe_request::API_KEY, 1),
1181                api_range(describe_groups_request::API_KEY, 5),
1182            ])
1183            .lookup(|group, _| Lookup::At(usize::from(!matches!(group, "kip848" | "classic"))))
1184            .answer(|_, api_key, version, body, _| {
1185                let reply = match api_key {
1186                    consumer_group_describe_request::API_KEY => {
1187                        let ids = consumer_describe_ids(body, version);
1188                        let groups = ids
1189                            .iter()
1190                            .map(|id| match id.as_str() {
1191                                "kip848" | "other" => consumer_entry(id, 0, None),
1192                                "classic" | "connect" => {
1193                                    consumer_entry(id, 69, Some("Group is not a consumer group."))
1194                                }
1195                                "missing" => {
1196                                    consumer_entry(id, 69, Some("Group missing does not exist."))
1197                                }
1198                                _ => consumer_entry(id, 35, None),
1199                            })
1200                            .collect();
1201                        crate::encode_test_struct_field!(
1202                            encode_response,
1203                            ConsumerGroupDescribeResponse,
1204                            groups,
1205                            groups,
1206                            version,
1207                            true
1208                        )
1209                    }
1210                    describe_groups_request::API_KEY => {
1211                        let ids = describe_groups_ids(body, version);
1212                        let groups = ids
1213                            .iter()
1214                            .map(|id| match id.as_str() {
1215                                "classic" => classic_entry(id, 0, "consumer"),
1216                                "connect" => classic_entry(id, 0, "connect"),
1217                                _ => classic_entry(id, 69, ""),
1218                            })
1219                            .collect();
1220                        encode_response(&classic_response(groups), version, version >= 5)
1221                    }
1222                    _ => return MockReply::Silent,
1223                };
1224                mock_response(reply)
1225            })
1226            .call()
1227            .await;
1228        let admin = cluster.admin(krabka_units::secs(5)).await;
1229
1230        let result = admin
1231            .describe_consumer_groups(&FALLBACK_GROUPS, default_describe_options())
1232            .await;
1233
1234        let [b0, b1] = nodes([cluster.addrs[0], cluster.addrs[1]]);
1235        let seen = cluster.stop();
1236        assert2::assert!(
1237            result
1238                == maplit::btreemap! {
1239                    "kip848".to_owned() => Ok(test_group_description(
1240                    "kip848",
1241                    b0.clone(),
1242                    GroupDescriptionKind::Consumer,
1243                )),
1244                    "classic".to_owned() => Ok(classic_group_description(b0)),
1245                    "other".to_owned() => Ok(test_group_description(
1246                    "other",
1247                    b1,
1248                    GroupDescriptionKind::Consumer,
1249                )),
1250                    "missing".to_owned() => Err(kafka_error(
1251                    69,
1252                    Some("Group missing does not exist.".to_owned())
1253                )),
1254                    "connect".to_owned() => Err(kafka_error(
1255                    -1,
1256                    Some("GroupId connect is not a consumer group (connect).".to_owned())
1257                )),
1258                }
1259        );
1260        let ids = |ids: &[&str]| ids.iter().map(|id| (*id).to_owned()).collect::<Vec<_>>();
1261        assert!(
1262            (
1263                seen.find_coordinator.clone(),
1264                requests_of(
1265                    &seen,
1266                    consumer_group_describe_request::API_KEY,
1267                    consumer_describe_ids
1268                ),
1269                requests_of(&seen, describe_groups_request::API_KEY, describe_groups_ids),
1270            ) == (
1271                vec![(
1272                    6,
1273                    ids(&["classic", "connect", "kip848", "missing", "other"])
1274                )],
1275                vec![
1276                    (0, 1, ids(&["classic", "kip848"])),
1277                    (1, 1, ids(&["connect", "missing", "other"])),
1278                ],
1279                vec![
1280                    (0, 5, ids(&["classic"])),
1281                    (1, 5, ids(&["connect", "missing"]))
1282                ],
1283            )
1284        );
1285    }
1286
1287    /// A coordinator that does not support `ConsumerGroupDescribe` gets
1288    /// `DescribeGroups` for every group, as Kafka's
1289    /// `handleUnsupportedVersionException` moves them to the classic API.
1290    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1291    async fn describe_consumer_groups_uses_describe_groups_on_an_old_coordinator() {
1292        let AdminGroupCluster { cluster, admin } = admin_group_cluster()
1293            .apis(vec![api_range(describe_groups_request::API_KEY, 5)])
1294            .answer(|_, api_key, version, body, _| {
1295                if api_key != describe_groups_request::API_KEY {
1296                    return MockReply::Silent;
1297                }
1298                let groups = describe_groups_ids(body, version)
1299                    .iter()
1300                    .map(|id| classic_entry(id, 0, "consumer"))
1301                    .collect();
1302                mock_response(encode_response(&classic_response(groups), version, true))
1303            })
1304            .call()
1305            .await;
1306
1307        let result = admin
1308            .describe_consumer_groups(&["classic"], default_describe_options())
1309            .await;
1310
1311        let first_broker = cluster.addrs[0];
1312        let _seen = cluster.stop();
1313        let b0 = node(first_broker, 0);
1314        assert!(
1315            result
1316                == maplit::btreemap! {
1317                    "classic".to_owned() => Ok(classic_group_description(b0)),
1318                }
1319        );
1320    }
1321
1322    /// Kafka's `handleUnsupportedVersionException`: a coordinator that lists
1323    /// `ConsumerGroupDescribe` only at versions this client does not speak
1324    /// gets each group through `DescribeGroups`.
1325    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1326    async fn describe_consumer_groups_falls_back_when_no_version_is_common() {
1327        let AdminGroupCluster { cluster, admin } = admin_group_cluster()
1328            .apis(vec![
1329                ApiVersionRange::builder()
1330                    .api_key(consumer_group_describe_request::API_KEY)
1331                    .min_version(50)
1332                    .max_version(50)
1333                    .build(),
1334                api_range(describe_groups_request::API_KEY, 5),
1335            ])
1336            .answer(|_, api_key, version, body, _| {
1337                // The fallback sends `DescribeGroups` only.
1338                assert!(api_key == describe_groups_request::API_KEY);
1339                let groups = describe_groups_ids(body, version)
1340                    .iter()
1341                    .map(|id| classic_entry(id, 0, "consumer"))
1342                    .collect();
1343                mock_response(encode_response(&classic_response(groups), version, true))
1344            })
1345            .call()
1346            .await;
1347
1348        let result = admin
1349            .describe_consumer_groups(&["classic"], default_describe_options())
1350            .await;
1351
1352        let first_broker = cluster.addrs[0];
1353        let _seen = cluster.stop();
1354        let b0 = node(first_broker, 0);
1355        assert!(
1356            result
1357                == maplit::btreemap! {
1358                    "classic".to_owned() => Ok(classic_group_description(b0)),
1359                }
1360        );
1361    }
1362
1363    /// Every describe handler retries `COORDINATOR_LOAD_IN_PROGRESS` on the
1364    /// same coordinator, finds the coordinator again on
1365    /// `COORDINATOR_NOT_AVAILABLE` and `NOT_COORDINATOR`, and fails every
1366    /// other code with the message of the answer (none for
1367    /// `describeClassicGroups`). The call sends the options' flag.
1368    #[yare::parameterized(
1369        classic_no_error = { DescribeApiCall::Classic, "no error", vec![0], 1, 1, Ok(()) },
1370        classic_load = { DescribeApiCall::Classic, "load in progress", vec![14, 0], 1, 2, Ok(()) },
1371        classic_not_coordinator = { DescribeApiCall::Classic, "not coordinator", vec![16, 0], 2, 2, Ok(()) },
1372        classic_not_available = { DescribeApiCall::Classic, "not available", vec![15, 0], 2, 2, Ok(()) },
1373        classic_authorization = { DescribeApiCall::Classic, "group authorization failed", vec![30], 1, 1, Err(30) },
1374        classic_not_found = { DescribeApiCall::Classic, "group id not found", vec![69], 1, 1, Err(69) },
1375        share_no_error = { DescribeApiCall::Share, "no error", vec![0], 1, 1, Ok(()) },
1376        share_load = { DescribeApiCall::Share, "load in progress", vec![14, 0], 1, 2, Ok(()) },
1377        share_not_coordinator = { DescribeApiCall::Share, "not coordinator", vec![16, 0], 2, 2, Ok(()) },
1378        share_not_available = { DescribeApiCall::Share, "not available", vec![15, 0], 2, 2, Ok(()) },
1379        share_authorization = { DescribeApiCall::Share, "group authorization failed", vec![30], 1, 1, Err(30) },
1380        share_not_found = { DescribeApiCall::Share, "group id not found", vec![69], 1, 1, Err(69) },
1381        streams_no_error = { DescribeApiCall::Streams, "no error", vec![0], 1, 1, Ok(()) },
1382        streams_load = { DescribeApiCall::Streams, "load in progress", vec![14, 0], 1, 2, Ok(()) },
1383        streams_not_coordinator = { DescribeApiCall::Streams, "not coordinator", vec![16, 0], 2, 2, Ok(()) },
1384        streams_not_available = { DescribeApiCall::Streams, "not available", vec![15, 0], 2, 2, Ok(()) },
1385        streams_authorization = { DescribeApiCall::Streams, "group authorization failed", vec![30], 1, 1, Err(30) },
1386        streams_not_found = { DescribeApiCall::Streams, "group id not found", vec![69], 1, 1, Err(69) },
1387    )]
1388    #[test_macro(tokio::test(flavor = "multi_thread", worker_threads = 2))]
1389    async fn describe_calls_retry_coordinator_errors_as_kafka_does(
1390        call: DescribeApiCall,
1391        name: &'static str,
1392        codes: Vec<i16>,
1393        expected_lookups: usize,
1394        expected_requests: usize,
1395        expected: Result<(), i16>,
1396    ) {
1397        let message = Some("from the broker".to_owned());
1398        let api_key = match call {
1399            DescribeApiCall::Classic => describe_groups_request::API_KEY,
1400            DescribeApiCall::Share => share_group_describe_request::API_KEY,
1401            DescribeApiCall::Streams => streams_group_describe_request::API_KEY,
1402        };
1403        let script = Arc::new(Mutex::new(codes));
1404        let reply_message = message.clone();
1405        let cluster = group_cluster()
1406            .count(1)
1407            .apis(vec![api_range(api_key, 6)])
1408            .lookup(|_, _| Lookup::At(0))
1409            .answer(move |_, key, version, _, _| {
1410                if key != api_key {
1411                    return MockReply::Silent;
1412                }
1413                let code = {
1414                    let mut script = script.lock().expect("script lock");
1415                    if script.len() > 1 {
1416                        script.remove(0)
1417                    } else {
1418                        script[0]
1419                    }
1420                };
1421                let body = match call {
1422                    DescribeApiCall::Classic => encode_described_group!(
1423                        DescribeGroupsResponse,
1424                        ClassicDescribedGroup,
1425                        code,
1426                        reply_message.clone(),
1427                        version,
1428                        version >= 5,
1429                    ),
1430                    DescribeApiCall::Share => encode_described_group!(
1431                        ShareGroupDescribeResponse,
1432                        ShareDescribedGroup,
1433                        code,
1434                        reply_message.clone(),
1435                        version,
1436                        true,
1437                    ),
1438                    DescribeApiCall::Streams => encode_described_group!(
1439                        StreamsGroupDescribeResponse,
1440                        StreamsDescribedGroup,
1441                        code,
1442                        reply_message.clone(),
1443                        version,
1444                        true,
1445                        topology = Some(Topology::default()),
1446                    ),
1447                };
1448                mock_response(body)
1449            })
1450            .call()
1451            .await;
1452        let admin = cluster.admin(krabka_units::secs(5)).await;
1453        let options = DescribeGroupsOptions::builder()
1454            .include_authorized_operations(true)
1455            .build();
1456        let result = describe_one_group(call, &admin, options).await;
1457        let seen = cluster.stop();
1458        let expected_message = match call {
1459            DescribeApiCall::Classic => None,
1460            DescribeApiCall::Share | DescribeApiCall::Streams => message.clone(),
1461        };
1462        let flags = seen
1463            .requests
1464            .iter()
1465            .map(|request| {
1466                let (version, body) = (request.version, &request.body[..]);
1467                match call {
1468                    DescribeApiCall::Classic => {
1469                        decode_request::<DescribeGroupsRequest>(body, version, version >= 5)
1470                            .include_authorized_operations
1471                    }
1472                    DescribeApiCall::Share => {
1473                        decode_request::<ShareGroupDescribeRequest>(body, version, true)
1474                            .include_authorized_operations
1475                    }
1476                    DescribeApiCall::Streams => {
1477                        decode_request::<StreamsGroupDescribeRequest>(body, version, true)
1478                            .include_authorized_operations
1479                    }
1480                }
1481            })
1482            .collect::<Vec<_>>();
1483        assert2::assert!(
1484            (result, seen.find_coordinator.len(), flags)
1485                == (
1486                    Some(expected.map_err(|code| kafka_error(code, expected_message))),
1487                    expected_lookups,
1488                    vec![true; expected_requests],
1489                ),
1490            "case {call:?} {name}"
1491        );
1492    }
1493
1494    /// Kafka 4.3.1 defines `StreamsGroupDescribe` v0 only, and its handler
1495    /// fails a group whose answer has no topology.
1496    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1497    async fn describe_streams_groups_sends_v0_and_needs_a_topology() {
1498        let AdminGroupCluster { cluster, admin } = admin_group_cluster()
1499            .apis(vec![api_range(streams_group_describe_request::API_KEY, 1)])
1500            .answer(|_, api_key, version, _, _| {
1501                if api_key != streams_group_describe_request::API_KEY {
1502                    return MockReply::Silent;
1503                }
1504                mock_response(crate::encode_test_struct_field!(
1505                    encode_response,
1506                    StreamsGroupDescribeResponse,
1507                    groups,
1508                    vec![
1509                        StreamsDescribedGroup {
1510                            group_id: "with".to_owned(),
1511                            topology: Some(Topology::default()),
1512                            ..Default::default()
1513                        },
1514                        StreamsDescribedGroup {
1515                            group_id: "without".to_owned(),
1516                            ..Default::default()
1517                        },
1518                    ],
1519                    version,
1520                    true,
1521                ))
1522            })
1523            .call()
1524            .await;
1525
1526        let result = default_streams_descriptions(&admin, &STREAMS_GROUPS).await;
1527
1528        let seen = cluster.stop();
1529        let versions = seen
1530            .requests
1531            .iter()
1532            .map(|request| request.version)
1533            .collect::<Vec<_>>();
1534        assert!(
1535            (result, versions)
1536                == (
1537                    maplit::btreemap! {
1538                        "with".to_owned() => Ok(StreamsDescribedGroup {
1539                        group_id: "with".to_owned(),
1540                        topology: Some(Topology::default()),
1541                        ..Default::default()
1542                    }),
1543                        "without".to_owned() => Err(kafka_error(
1544                        -1,
1545                        Some("Topology information is missing".to_owned())
1546                    )),
1547                    },
1548                    vec![0],
1549                )
1550        );
1551    }
1552}