Skip to main content

krabka_client_admin/
groups.rs

1//! Consumer-group admin APIs: [`AdminClient::list_groups`] and
2//! [`AdminClient::list_consumer_group_offsets`].
3//!
4//! These wrap the `ListGroups` (`api_key`=16), `OffsetCommit` (`api_key`=8)
5//! and `OffsetFetch` (`api_key`=9) RPCs.
6//!
7//! ## `ListGroups` fan-out
8//!
9//! A broker lists only the groups that it coordinates.
10//! [`AdminClient::list_groups`] sends `ListGroups` to every broker in the
11//! metadata and merges the answers, as Apache Kafka's
12//! `KafkaAdminClient.listGroups` does.
13//!
14//! ## `OffsetCommit` version note
15//!
16//! [`AdminClient::alter_consumer_group_offsets`] sends `OffsetCommit` at v9 or
17//! lower, where the request names each topic. Apache Kafka's
18//! `AlterConsumerGroupOffsetsHandler` builds its request with
19//! `OffsetCommitRequest.Builder.forTopicNames`, which caps the version at 9.
20//!
21//! ## `OffsetFetch` version note
22//!
23//! [`AdminClient::list_consumer_group_offsets`] sends `OffsetFetch` at v2 to
24//! v9, where the response names each topic. Apache Kafka's
25//! `ListConsumerGroupOffsetsHandler` builds its request with
26//! `OffsetFetchRequest.Builder.forTopicNames`, which caps the version at 9.
27//!
28//! ## `OffsetFetch` retries
29//!
30//! [`AdminClient::list_consumer_group_offsets`] retries the coordinator error
31//! codes as Apache Kafka's `ListConsumerGroupOffsetsHandler.handleGroupError`
32//! does, until Kafka's default `default.api.timeout.ms` (60 s) elapses.
33
34use std::{
35    collections::{BTreeMap, BTreeSet},
36    iter,
37};
38
39use futures_util::future::join_all;
40use krabka_client_core::{
41    AllTopicsOffsetFetch, AuthenticationError, ClientError, Connection, ConnectionOptions,
42    CoordinatorKeyType, MembersOnlyLeaveGroup, SaslAuthenticationError, TopicNameOffsetCommit,
43    build_find_coordinator, coordinator_endpoint,
44};
45use krabka_protocol::owned::{
46    alter_share_group_offsets_request::{
47        AlterShareGroupOffsetsRequest, AlterShareGroupOffsetsRequestPartition,
48        AlterShareGroupOffsetsRequestTopic,
49    },
50    alter_share_group_offsets_response::AlterShareGroupOffsetsResponse,
51    delete_share_group_offsets_request::{
52        DeleteShareGroupOffsetsRequest, DeleteShareGroupOffsetsRequestTopic,
53    },
54    delete_share_group_offsets_response::DeleteShareGroupOffsetsResponse,
55    leave_group_request::{LeaveGroupRequest, MemberIdentity},
56    leave_group_response::LeaveGroupResponse,
57    list_groups_request::{self, ListGroupsRequest},
58    list_groups_response::ListedGroup,
59    metadata_request::MetadataRequest,
60    metadata_response::MetadataResponseBroker,
61    offset_commit_request::{
62        OffsetCommitRequest, OffsetCommitRequestPartition, OffsetCommitRequestTopic,
63    },
64    offset_commit_response::{
65        OffsetCommitResponse, OffsetCommitResponsePartition, OffsetCommitResponseTopic,
66    },
67    offset_delete_request::{
68        OffsetDeleteRequest, OffsetDeleteRequestPartition, OffsetDeleteRequestTopic,
69    },
70    offset_delete_response::{
71        OffsetDeleteResponse, OffsetDeleteResponsePartition, OffsetDeleteResponseTopic,
72    },
73    offset_fetch_request::{OffsetFetchRequest, OffsetFetchRequestGroup},
74    offset_fetch_response::{
75        OffsetFetchResponse, OffsetFetchResponsePartition, OffsetFetchResponsePartitions,
76        OffsetFetchResponseTopic, OffsetFetchResponseTopics,
77    },
78};
79use tokio::time::{Instant, sleep, sleep_until, timeout_at};
80
81use crate::{
82    AdminClient, AdminError, KafkaError, format_host_port,
83    group_descriptions::DescribeGroupsOptions,
84    kafka_error_if, kafka_error_name,
85    retry::{CoordinatorRetry, RetryAction, RetryPolicy, connection_failure_action},
86    send_connection_at_least,
87};
88
89macro_rules! group_offset_attempt {
90    ($this:ident, $group:expr, $find_coordinator:expr, $request:expr, $handle:expr) => {{
91        if $find_coordinator && let Err(action) = $this.find_group_coordinator_attempt($group).await
92        {
93            return action;
94        }
95        match $this.conn.send($request).await {
96            Ok(response) => $handle($group, response),
97            Err(error) => connection_failure_action(error),
98        }
99    }};
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct ConsumerGroupOffsetOutcome {
104    pub topic: String,
105    pub partition: i32,
106    pub error: Option<KafkaError>,
107}
108
109struct GroupOffsetResponseTopic<P> {
110    name: String,
111    partitions: Vec<P>,
112}
113
114trait IntoGroupOffsetResponseTopic {
115    type Partition: GroupOffsetResponsePartition;
116
117    fn into_group_offset_topic(self) -> GroupOffsetResponseTopic<Self::Partition>;
118}
119
120trait GroupOffsetResponsePartition {
121    fn index(&self) -> i32;
122    fn error_code(&self) -> i16;
123}
124
125macro_rules! impl_group_offset_response {
126    ($topic:ty, $partition:ty) => {
127        impl IntoGroupOffsetResponseTopic for $topic {
128            type Partition = $partition;
129
130            fn into_group_offset_topic(self) -> GroupOffsetResponseTopic<Self::Partition> {
131                GroupOffsetResponseTopic {
132                    name: self.name,
133                    partitions: self.partitions,
134                }
135            }
136        }
137
138        impl GroupOffsetResponsePartition for $partition {
139            fn index(&self) -> i32 {
140                self.partition_index
141            }
142            fn error_code(&self) -> i16 {
143                self.error_code
144            }
145        }
146    };
147}
148
149impl_group_offset_response!(OffsetCommitResponseTopic, OffsetCommitResponsePartition);
150impl_group_offset_response!(OffsetDeleteResponseTopic, OffsetDeleteResponsePartition);
151
152fn group_offset_outcomes<T: IntoGroupOffsetResponseTopic>(
153    topics: impl IntoIterator<Item = T>,
154) -> Vec<ConsumerGroupOffsetOutcome> {
155    topics
156        .into_iter()
157        .flat_map(|topic| {
158            let GroupOffsetResponseTopic { name, partitions } = topic.into_group_offset_topic();
159            partitions
160                .into_iter()
161                .map(move |partition| ConsumerGroupOffsetOutcome {
162                    topic: name.clone(),
163                    partition: partition.index(),
164                    error: kafka_error_if(partition.error_code(), None),
165                })
166        })
167        .collect()
168}
169
170/// Offsets keyed by topic name and partition.
171pub type GroupOffsets = BTreeMap<(String, i32), i64>;
172
173#[derive(Clone, Copy)]
174enum GroupOffsetKind {
175    Consumer,
176    Share,
177}
178
179#[derive(Clone, Copy)]
180struct GroupOffsetUpdate<'a> {
181    kind: GroupOffsetKind,
182    group: &'a str,
183    offsets: &'a GroupOffsets,
184}
185
186impl<'a> GroupOffsetUpdate<'a> {
187    fn consumer(group: &'a str, offsets: &'a GroupOffsets) -> Self {
188        Self {
189            kind: GroupOffsetKind::Consumer,
190            group,
191            offsets,
192        }
193    }
194
195    fn share(group: &'a str, offsets: &'a GroupOffsets) -> Self {
196        Self {
197            kind: GroupOffsetKind::Share,
198            group,
199            offsets,
200        }
201    }
202}
203
204struct GroupOffsetAttempt<'a> {
205    update: GroupOffsetUpdate<'a>,
206    find_coordinator: bool,
207}
208
209/// The state of a group, as Apache Kafka's `GroupState` names it.
210///
211/// `ListGroups` v4 (KIP-518) filters groups by these names.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
213pub enum GroupState {
214    /// A state name that the client does not know.
215    Unknown,
216    PreparingRebalance,
217    CompletingRebalance,
218    Stable,
219    Dead,
220    Empty,
221    Assigning,
222    Reconciling,
223    NotReady,
224}
225
226impl GroupState {
227    const ALL: [Self; 9] = [
228        Self::Unknown,
229        Self::PreparingRebalance,
230        Self::CompletingRebalance,
231        Self::Stable,
232        Self::Dead,
233        Self::Empty,
234        Self::Assigning,
235        Self::Reconciling,
236        Self::NotReady,
237    ];
238
239    /// The wire name of the state, for example `"Stable"`.
240    #[must_use]
241    pub const fn as_str(self) -> &'static str {
242        match self {
243            Self::Unknown => "Unknown",
244            Self::PreparingRebalance => "PreparingRebalance",
245            Self::CompletingRebalance => "CompletingRebalance",
246            Self::Stable => "Stable",
247            Self::Dead => "Dead",
248            Self::Empty => "Empty",
249            Self::Assigning => "Assigning",
250            Self::Reconciling => "Reconciling",
251            Self::NotReady => "NotReady",
252        }
253    }
254}
255
256macro_rules! impl_group_name_parse {
257    ($type:ty, $($doc:literal),+ $(,)?) => {
258        impl $type {
259            $(#[doc = $doc])+
260            #[must_use]
261            pub fn parse(name: &str) -> Self {
262                parse_group_name(Self::ALL, name, Self::as_str, Self::Unknown)
263            }
264        }
265    };
266}
267impl_group_name_parse!(
268    GroupState,
269    "Reads a state name without regard to case. A name that the client does not know gives [`GroupState::Unknown`], as Kafka's `GroupState.parse` does."
270);
271
272macro_rules! impl_display_from_as_str {
273    ($type:ty) => {
274        impl std::fmt::Display for $type {
275            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276                f.write_str(self.as_str())
277            }
278        }
279    };
280}
281impl_display_from_as_str!(GroupState);
282
283/// The type of a group, as Apache Kafka's `GroupType` names it.
284///
285/// `ListGroups` v5 (KIP-848) filters groups by these names.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
287pub enum GroupType {
288    /// A type name that the client does not know.
289    Unknown,
290    Consumer,
291    Classic,
292    Share,
293    Streams,
294}
295
296impl GroupType {
297    const ALL: [Self; 5] = [
298        Self::Unknown,
299        Self::Consumer,
300        Self::Classic,
301        Self::Share,
302        Self::Streams,
303    ];
304
305    /// The wire name of the type, for example `"Classic"`.
306    #[must_use]
307    pub const fn as_str(self) -> &'static str {
308        match self {
309            Self::Unknown => "Unknown",
310            Self::Consumer => "Consumer",
311            Self::Classic => "Classic",
312            Self::Share => "Share",
313            Self::Streams => "Streams",
314        }
315    }
316}
317impl_group_name_parse!(
318    GroupType,
319    "Reads a type name without regard to case. A name that the client does not know gives [`GroupType::Unknown`], as Kafka's `GroupType.parse` does."
320);
321
322fn parse_group_name<T: Copy>(
323    values: impl IntoIterator<Item = T>,
324    name: &str,
325    as_str: fn(T) -> &'static str,
326    unknown: T,
327) -> T {
328    values
329        .into_iter()
330        .find(|value| as_str(*value).eq_ignore_ascii_case(name))
331        .unwrap_or(unknown)
332}
333
334impl_display_from_as_str!(GroupType);
335
336/// The options of [`AdminClient::list_groups`], as Apache Kafka's
337/// `ListGroupsOptions` holds them. An empty set does not filter.
338#[derive(Debug, Clone, Default, PartialEq, Eq, bon::Builder)]
339pub struct ListGroupsOptions {
340    /// The broker lists only groups in these states (`states_filter`).
341    #[builder(default)]
342    pub group_states: BTreeSet<GroupState>,
343    /// The broker lists only groups of these types (`types_filter`).
344    #[builder(default)]
345    pub types: BTreeSet<GroupType>,
346    /// The client keeps only groups with these protocol types.
347    #[builder(default)]
348    pub protocol_types: BTreeSet<String>,
349}
350
351impl ListGroupsOptions {
352    /// Classic and consumer groups with the `consumer` protocol type or no
353    /// protocol type, as Kafka's `ListGroupsOptions.forConsumerGroups` selects.
354    #[must_use]
355    pub fn for_consumer_groups() -> Self {
356        Self::builder()
357            .types(maplit::btreeset! {
358                GroupType::Classic,
359                GroupType::Consumer,
360            })
361            .protocol_types(maplit::btreeset! {
362                String::new(),
363                "consumer".to_owned(),
364            })
365            .build()
366    }
367
368    /// Share groups, as Kafka's `ListGroupsOptions.forShareGroups` selects.
369    #[must_use]
370    pub fn for_share_groups() -> Self {
371        Self::builder()
372            .types(maplit::btreeset! {
373                GroupType::Share,
374            })
375            .build()
376    }
377
378    /// Streams groups, as Kafka's `ListGroupsOptions.forStreamsGroups`
379    /// selects.
380    #[must_use]
381    pub fn for_streams_groups() -> Self {
382        Self::builder()
383            .types(maplit::btreeset! {
384                GroupType::Streams,
385            })
386            .build()
387    }
388}
389
390/// One group of [`AdminClient::list_groups`], as Apache Kafka's
391/// `GroupListing` holds it.
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct GroupListing {
394    pub group_id: String,
395    /// `None` when the broker sends no type (`ListGroups` v4 or lower).
396    pub group_type: Option<GroupType>,
397    /// The protocol type, for example `"consumer"`. Empty for a simple
398    /// consumer group.
399    pub protocol_type: String,
400    /// `None` when the broker sends no state (`ListGroups` v3 or lower).
401    pub group_state: Option<GroupState>,
402}
403
404impl GroupListing {
405    /// Whether the group is a classic group with no protocol type, as Kafka's
406    /// `GroupListing.isSimpleConsumerGroup` decides.
407    ///
408    /// A group with no type (`None`, from `ListGroups` v4 or lower) gives
409    /// `false`, as in Kafka, where `type.filter(gt -> gt == CLASSIC)` is empty
410    /// for `Optional.empty()`. Such a broker lists only classic groups, so a
411    /// caller that wants Kafka's older `listConsumerGroups` answer
412    /// (`ConsumerGroupListing.isSimpleConsumerGroup`, which is
413    /// `protocolType.isEmpty()`) reads [`GroupListing::protocol_type`] alone.
414    #[must_use]
415    pub fn is_simple_consumer_group(&self) -> bool {
416        self.group_type == Some(GroupType::Classic) && self.protocol_type.is_empty()
417    }
418}
419
420impl From<ListedGroup> for GroupListing {
421    fn from(group: ListedGroup) -> Self {
422        Self {
423            group_type: (!group.group_type.is_empty()).then(|| GroupType::parse(&group.group_type)),
424            group_state: (!group.group_state.is_empty())
425                .then(|| GroupState::parse(&group.group_state)),
426            group_id: group.group_id,
427            protocol_type: group.protocol_type,
428        }
429    }
430}
431
432/// The failure of `ListGroups` on one broker.
433///
434/// Kafka's `listGroups` puts one exception for each failed broker in
435/// `ListGroupsResult.errors`. `error` holds the Kafka error code of that
436/// exception, as Kafka's `ApiError.fromThrowable` gives it.
437#[derive(Debug, Clone, PartialEq, Eq)]
438pub struct ListGroupsError {
439    pub node_id: i32,
440    pub host: String,
441    pub port: i32,
442    pub error: KafkaError,
443}
444
445/// The result of [`AdminClient::list_groups`], as Apache Kafka's
446/// `ListGroupsResult` holds it.
447#[derive(Debug, Clone, PartialEq, Eq)]
448pub struct ListGroupsResult {
449    /// The groups of every broker that answered, one entry for each group
450    /// id, in group id order.
451    pub valid: Vec<GroupListing>,
452    /// One entry for each broker that failed, in metadata order.
453    pub errors: Vec<ListGroupsError>,
454}
455
456impl ListGroupsResult {
457    /// The groups when no broker failed, as Kafka's `ListGroupsResult.all`
458    /// gives them.
459    ///
460    /// # Errors
461    /// Returns the first broker failure.
462    pub fn all(self) -> Result<Vec<GroupListing>, ListGroupsError> {
463        match self.errors.into_iter().next() {
464            Some(error) => Err(error),
465            None => Ok(self.valid),
466        }
467    }
468}
469
470macro_rules! group_offsets_method_body {
471    (delete_consumer, $admin:ident, $group:ident, $input:ident) => {
472        $admin
473            .delete_consumer_group_offsets_with_retry($group, $input, $admin.retry)
474            .await
475    };
476    (delete_streams, $admin:ident, $group:ident, $input:ident) => {
477        $admin.delete_consumer_group_offsets($group, $input).await
478    };
479    (alter_consumer, $admin:ident, $group:ident, $input:ident) => {
480        $admin
481            .alter_group_offsets(GroupOffsetUpdate::consumer($group, $input))
482            .await
483    };
484    (alter_share, $admin:ident, $group:ident, $input:ident) => {
485        $admin
486            .alter_group_offsets(GroupOffsetUpdate::share($group, $input))
487            .await
488    };
489}
490
491macro_rules! group_offsets_method {
492    ($(#[$meta:meta])* $name:ident($input:ident: $input_type:ty) => $kind:ident) => {
493        $(#[$meta])*
494        pub async fn $name(
495            &mut self,
496            group: &str,
497            $input: &$input_type,
498        ) -> Result<Vec<ConsumerGroupOffsetOutcome>, AdminError> {
499            group_offsets_method_body!($kind, self, group, $input)
500        }
501    };
502}
503
504macro_rules! retry_group_call {
505    ($retry_policy:expr, |$find:ident| $attempt:expr) => {{
506        let mut retry = CoordinatorRetry::new($retry_policy);
507        loop {
508            let $find = retry.find_coordinator();
509            let action = retry.run($attempt).await;
510            if let Some(result) = retry.next(action).await {
511                return result;
512            }
513        }
514    }};
515}
516
517impl AdminClient {
518    group_offsets_method! {
519        /// Commits explicit offsets for an inactive consumer group.
520        ///
521        /// The request names each topic, so the client negotiates `OffsetCommit`
522        /// v9 or lower, as Apache Kafka's `AlterConsumerGroupOffsetsHandler` does.
523        ///
524        /// The call retries the partition error codes as Apache Kafka's
525        /// `AlterConsumerGroupOffsetsHandler.handleError` does. One retriable code
526        /// in the response makes the call send all the offsets again:
527        ///
528        /// - `COORDINATOR_LOAD_IN_PROGRESS` (14) and `REBALANCE_IN_PROGRESS` (27):
529        ///   send the request again to the same coordinator.
530        /// - `COORDINATOR_NOT_AVAILABLE` (15) and `NOT_COORDINATOR` (16): find the
531        ///   coordinator again, then send the request again.
532        ///
533        /// A `FindCoordinator` answer of 14 or 15 also makes the call find the
534        /// coordinator again. A failed or lost connection to the coordinator,
535        /// including a TLS or SASL handshake that stops before the broker gives a
536        /// verdict, makes the call find the coordinator again, as Kafka's
537        /// `AdminApiDriver.onFailure` does. The call waits between attempts with
538        /// Kafka's backoff, and it stops when Kafka's default
539        /// `default.api.timeout.ms` (60 s) elapses. A call whose retries are
540        /// unresolved at the deadline, or whose attempt is still running, fails
541        /// with [`ClientError::Timeout`], as Kafka gives a `TimeoutException`.
542        ///
543        /// # Errors
544        /// Returns an error when encoding, transport, or response handling fails.
545        /// Returns [`ClientError::Server`] when `FindCoordinator` answers with an
546        /// error code that Kafka does not retry. Returns
547        /// [`ClientError::Timeout`] at the deadline. Returns [`ClientError::IncompatibleVersion`] when the
548        /// coordinator does not support `OffsetCommit` v9 or lower.
549        ///
550        /// [`ClientError::IncompatibleVersion`]: ClientError::IncompatibleVersion
551        /// [`ClientError::Server`]: ClientError::Server
552        /// [`ClientError::Timeout`]: ClientError::Timeout
553        alter_consumer_group_offsets(offsets: GroupOffsets) => alter_consumer
554    }
555
556    async fn alter_group_offsets(
557        &self,
558        update: GroupOffsetUpdate<'_>,
559    ) -> Result<Vec<ConsumerGroupOffsetOutcome>, AdminError> {
560        self.alter_group_offsets_with_retry(update, self.retry)
561            .await
562    }
563
564    async fn alter_group_offsets_with_retry(
565        &self,
566        update: GroupOffsetUpdate<'_>,
567        retry: RetryPolicy,
568    ) -> Result<Vec<ConsumerGroupOffsetOutcome>, AdminError> {
569        retry_group_call!(retry, |find_coordinator| {
570            self.alter_group_offsets_attempt(GroupOffsetAttempt {
571                update,
572                find_coordinator,
573            })
574        })
575    }
576
577    /// Sends one update attempt, finding the coordinator first when requested.
578    async fn alter_group_offsets_attempt(
579        &self,
580        attempt: GroupOffsetAttempt<'_>,
581    ) -> RetryAction<Vec<ConsumerGroupOffsetOutcome>> {
582        let GroupOffsetAttempt {
583            update,
584            find_coordinator,
585        } = attempt;
586        if find_coordinator
587            && let Err(action) = self.find_group_coordinator_attempt(update.group).await
588        {
589            return action;
590        }
591        match update.kind {
592            GroupOffsetKind::Consumer => self
593                .conn
594                .send(offset_commit_request(update.group, update.offsets))
595                .await
596                .map_or_else(connection_failure_action, offset_commit_retry_action),
597            GroupOffsetKind::Share => self
598                .conn
599                .send(alter_share_group_offsets_request(
600                    update.group,
601                    update.offsets,
602                ))
603                .await
604                .map_or_else(connection_failure_action, |response| {
605                    alter_share_group_offsets_retry_action(update.offsets, response)
606                }),
607        }
608    }
609
610    /// Lists the groups of the whole cluster, as Apache Kafka's
611    /// `KafkaAdminClient.listGroups` does.
612    ///
613    /// A broker lists only the groups that it coordinates. The call first
614    /// sends `Metadata` to find every broker. It then sends `ListGroups` to
615    /// each broker at the same time, on a new connection, and merges the
616    /// answers:
617    ///
618    /// - The result has one [`GroupListing`] for each group id. A group that
619    ///   two brokers list appears once.
620    /// - A broker that fails gives one [`ListGroupsError`]. The groups of the
621    ///   other brokers stay in [`ListGroupsResult::valid`].
622    /// - `COORDINATOR_LOAD_IN_PROGRESS` (14) and `COORDINATOR_NOT_AVAILABLE`
623    ///   (15) make the call send `ListGroups` to that broker again. A failed
624    ///   connection or a lost connection also makes the call try again. A
625    ///   rejected TLS or SASL authentication does not. It is an error for that
626    ///   broker at once.
627    /// - Kafka's default `default.api.timeout.ms` (60 s) is the deadline of
628    ///   each broker. Each attempt on a broker (connection, TLS and SASL
629    ///   handshakes, and request) gets the time that remains. A broker that
630    ///   has not answered at the deadline gives `REQUEST_TIMED_OUT` (7), with
631    ///   the last error in the message.
632    /// - A filter that the broker version does not support gives
633    ///   `UNSUPPORTED_VERSION` (35) for that broker. `states_filter` needs
634    ///   `ListGroups` v4 (KIP-518) and `types_filter` needs v5 (KIP-848).
635    ///   Below v5, the call omits a type filter that holds `Classic`, and
636    ///   `Consumer` at most, as Kafka's `ListGroupsRequest.Builder.build`
637    ///   does.
638    /// - [`ListGroupsOptions::protocol_types`] filters on the client.
639    ///
640    /// # Errors
641    /// Returns an error when the `Metadata` request fails, or when the
642    /// metadata names no broker after the timeout. A failure on one broker is
643    /// an entry in [`ListGroupsResult::errors`], not an error of the call.
644    pub async fn list_groups(
645        &self,
646        options: &ListGroupsOptions,
647    ) -> Result<ListGroupsResult, AdminError> {
648        self.list_groups_with_retry(options, self.retry).await
649    }
650
651    async fn list_groups_with_retry(
652        &self,
653        options: &ListGroupsOptions,
654        retry: RetryPolicy,
655    ) -> Result<ListGroupsResult, AdminError> {
656        let start = Instant::now();
657        let brokers = self.list_groups_brokers(start, retry).await?;
658        let request = ListGroupsRequest {
659            states_filter: options
660                .group_states
661                .iter()
662                .map(|state| state.as_str().to_owned())
663                .collect(),
664            types_filter: options
665                .types
666                .iter()
667                .map(|group_type| group_type.as_str().to_owned())
668                .collect(),
669            ..Default::default()
670        };
671        let min_version = list_groups_min_version(options);
672        let answers = join_all(brokers.iter().map(|broker| {
673            list_groups_on_broker(
674                broker,
675                self.options.clone(),
676                request.clone(),
677                min_version,
678                start,
679                retry,
680            )
681        }))
682        .await;
683
684        let mut listings = BTreeMap::new();
685        let mut errors = Vec::new();
686        for (broker, answer) in brokers.into_iter().zip(answers) {
687            match answer {
688                Ok(groups) => {
689                    for group in groups {
690                        if options.protocol_types.is_empty()
691                            || options.protocol_types.contains(&group.protocol_type)
692                        {
693                            let listing = GroupListing::from(group);
694                            listings.insert(listing.group_id.clone(), listing);
695                        }
696                    }
697                }
698                Err(error) => errors.push(ListGroupsError {
699                    node_id: broker.node_id,
700                    host: broker.host,
701                    port: broker.port,
702                    error,
703                }),
704            }
705        }
706        Ok(ListGroupsResult {
707            valid: listings.into_values().collect(),
708            errors,
709        })
710    }
711
712    /// Sends `Metadata` for no topics and returns each broker once. Kafka's
713    /// `listGroups` retries a broker list that is empty
714    /// (`StaleMetadataException`) until the timeout.
715    async fn list_groups_brokers(
716        &self,
717        start: Instant,
718        retry: RetryPolicy,
719    ) -> Result<Vec<MetadataResponseBroker>, AdminError> {
720        let mut backoff = retry.initial_backoff;
721        loop {
722            let response = self
723                .conn
724                .send(MetadataRequest {
725                    topics: Some(Vec::new()),
726                    allow_auto_topic_creation: true,
727                    ..Default::default()
728                })
729                .await?;
730            let mut brokers = response.brokers;
731            let mut seen = BTreeSet::new();
732            brokers.retain(|broker| seen.insert(broker.node_id));
733            if !brokers.is_empty() {
734                return Ok(brokers);
735            }
736            if start.elapsed() >= retry.timeout {
737                return Err(AdminError::Protocol(
738                    "failed to find brokers to send ListGroups: metadata has no brokers".to_owned(),
739                ));
740            }
741            sleep(backoff).await;
742            backoff = backoff.saturating_mul(2).min(retry.max_backoff);
743        }
744    }
745
746    /// Returns `(topic, partition) → committed_offset` for the named group.
747    ///
748    /// The call requests all topics and partitions (`topics: None`). It skips
749    /// a partition that has an error code, and a partition with a committed
750    /// offset below 0, which means no committed offset.
751    ///
752    /// The response names each topic, so the client negotiates `OffsetFetch`
753    /// v2 to v9, as Apache Kafka's `ListConsumerGroupOffsetsHandler` does.
754    ///
755    /// The call retries the coordinator error codes as Apache Kafka's
756    /// `ListConsumerGroupOffsetsHandler.handleGroupError` does:
757    ///
758    /// - `COORDINATOR_LOAD_IN_PROGRESS` (14): send the request again to the
759    ///   same coordinator.
760    /// - `COORDINATOR_NOT_AVAILABLE` (15) and `NOT_COORDINATOR` (16): find the
761    ///   coordinator again, then send the request again.
762    ///
763    /// A `FindCoordinator` answer of 14 or 15 also makes the call find the
764    /// coordinator again, as Kafka's `CoordinatorStrategy.handleError` does. A
765    /// failed or lost connection to the coordinator does the same, as
766    /// `AdminApiDriver.onFailure` does. The call waits between attempts, and
767    /// it stops when Kafka's default `default.api.timeout.ms` (60 s) elapses.
768    /// A call whose retries are unresolved at the deadline, or whose attempt
769    /// is still running, fails with [`ClientError::Timeout`].
770    ///
771    /// # Errors
772    /// Returns an error when encoding, transport, or response handling fails.
773    /// Returns [`AdminError::Broker`] when the coordinator answers with a
774    /// group error code that Kafka does not retry. Returns
775    /// [`ClientError::Server`] when `FindCoordinator` answers with an error
776    /// code that Kafka does not retry. Returns [`ClientError::Timeout`] at
777    /// the deadline. Returns
778    /// [`ClientError::IncompatibleVersion`] when the coordinator does not
779    /// support `OffsetFetch` v2 to v9.
780    ///
781    /// [`ClientError::IncompatibleVersion`]: ClientError::IncompatibleVersion
782    /// [`ClientError::Server`]: ClientError::Server
783    /// [`ClientError::Timeout`]: ClientError::Timeout
784    pub async fn list_consumer_group_offsets(
785        &mut self,
786        group: &str,
787    ) -> Result<GroupOffsets, AdminError> {
788        self.list_consumer_group_offsets_with_retry(group, self.retry)
789            .await
790    }
791
792    async fn list_consumer_group_offsets_with_retry(
793        &self,
794        group: &str,
795        retry: RetryPolicy,
796    ) -> Result<GroupOffsets, AdminError> {
797        retry_group_call!(retry, |find| self.offset_fetch_attempt(group, find))
798    }
799
800    /// One attempt of `list_consumer_group_offsets`. When `find_coordinator`
801    /// is set, the attempt first finds the group coordinator and connects to
802    /// it.
803    async fn offset_fetch_attempt(
804        &self,
805        group: &str,
806        find_coordinator: bool,
807    ) -> RetryAction<GroupOffsets> {
808        let request = offset_fetch_request(group);
809        group_offset_attempt!(self, group, find_coordinator, request, |group, response| {
810            offset_fetch_retry_action(committed_offsets(group, response))
811        })
812    }
813
814    /// Finds the group coordinator and connects to it. A `FindCoordinator`
815    /// answer that Kafka's `CoordinatorStrategy.handleError` retries, and a
816    /// connection failure to the coordinator that it names, give
817    /// [`RetryAction::FindCoordinator`], as `AdminApiDriver.onFailure` looks
818    /// the coordinator up again after a disconnect. Another failure gives
819    /// [`RetryAction::Done`].
820    async fn find_group_coordinator_attempt<T>(&self, group: &str) -> Result<(), RetryAction<T>> {
821        match self.reconnect_group_coordinator(group).await {
822            Ok(()) => Ok(()),
823            Err(AdminError::Transport(ClientError::Server { error_code }))
824                if is_retriable_find_coordinator_error(error_code) =>
825            {
826                Err(RetryAction::FindCoordinator(Err(AdminError::Transport(
827                    ClientError::Server { error_code },
828                ))))
829            }
830            Err(error) => Err(connection_failure_action(error)),
831        }
832    }
833
834    async fn reconnect_group_coordinator(&self, group: &str) -> Result<(), AdminError> {
835        let response = self
836            .conn
837            .send(build_find_coordinator(group, CoordinatorKeyType::Group))
838            .await?;
839        let coordinator = coordinator_endpoint(group, response)?;
840        self.reconnect(&format_host_port(&coordinator.host, coordinator.port))
841            .await
842    }
843
844    group_offsets_method! {
845        /// Deletes committed offsets of an inactive consumer group for the named
846        /// partitions with `OffsetDelete`, as Apache Kafka's
847        /// `deleteConsumerGroupOffsets` does. Routes to the group's coordinator
848        /// with the same retry policy as
849        /// [`AdminClient::alter_consumer_group_offsets`].
850        ///
851        /// # Errors
852        /// Returns an error when encoding, transport, or response handling fails.
853        /// Returns [`AdminError::Broker`] when the coordinator's top-level answer
854        /// carries a group error code that Kafka does not retry, such as
855        /// `GROUP_ID_NOT_FOUND` or `NON_EMPTY_GROUP`.
856        delete_consumer_group_offsets(partitions: [(String, i32)]) => delete_consumer
857    }
858
859    group_offsets_method! {
860        /// Deletes committed offsets of an inactive streams group. Apache Kafka's
861        /// `KafkaAdminClient.deleteStreamsGroupOffsets` delegates to
862        /// `deleteConsumerGroupOffsets` verbatim, so this does too.
863        ///
864        /// # Errors
865        /// See [`AdminClient::delete_consumer_group_offsets`].
866        delete_streams_group_offsets(partitions: [(String, i32)]) => delete_streams
867    }
868
869    async fn delete_consumer_group_offsets_with_retry(
870        &self,
871        group: &str,
872        partitions: &[(String, i32)],
873        retry: RetryPolicy,
874    ) -> Result<Vec<ConsumerGroupOffsetOutcome>, AdminError> {
875        retry_group_call!(retry, |find| self
876            .offset_delete_attempt(group, partitions, find))
877    }
878
879    async fn offset_delete_attempt(
880        &self,
881        group: &str,
882        partitions: &[(String, i32)],
883        find_coordinator: bool,
884    ) -> RetryAction<Vec<ConsumerGroupOffsetOutcome>> {
885        let request = offset_delete_request(group, partitions);
886        group_offset_attempt!(self, group, find_coordinator, request, |group, response| {
887            offset_delete_retry_action(group, response)
888        })
889    }
890
891    /// Removes members from a consumer group without waiting for their
892    /// session timeout, with `LeaveGroup`, as Apache Kafka's
893    /// `removeMembersFromConsumerGroup` does. Routes to the group's
894    /// coordinator with the same retry policy as
895    /// [`AdminClient::alter_consumer_group_offsets`].
896    ///
897    /// Each member is named by its `group.instance.id`, as Kafka's
898    /// `MemberToRemove` names it, and is sent with the member id
899    /// `UNKNOWN_MEMBER_ID` (empty). An empty `members` removes every member of
900    /// the group, as Kafka's `removeAll` mode does: the call first describes
901    /// the group with [`AdminClient::describe_consumer_groups`] and names each
902    /// member by its instance id, or by its member id when it has none.
903    ///
904    /// Each member carries `options.reason`, cut to 255 characters, or
905    /// Kafka's default `"member was removed by an admin"` when the reason is
906    /// `None` or empty. `LeaveGroup`'s member list needs v3, so the client
907    /// negotiates v3 or higher, as Kafka's `LeaveGroupRequest.Builder` does.
908    ///
909    /// The result maps each member of the `LeaveGroup` answer, by member id
910    /// and instance id, to its error, as Kafka's
911    /// `RemoveMembersFromConsumerGroupHandler.handleResponse` does.
912    ///
913    /// # Errors
914    /// Returns an error when encoding, transport, or response handling fails.
915    /// Returns [`AdminError::Broker`] when the coordinator's top-level answer
916    /// carries a group error code that Kafka does not retry, or when the
917    /// `removeAll` describe fails for the group. Returns
918    /// [`ClientError::IncompatibleVersion`] when the coordinator does not
919    /// support `LeaveGroup` v3 or higher.
920    ///
921    /// [`ClientError::IncompatibleVersion`]: ClientError::IncompatibleVersion
922    pub async fn remove_members_from_consumer_group(
923        &mut self,
924        group: &str,
925        members: &[MemberToRemove],
926        options: &RemoveMembersOptions,
927    ) -> Result<RemovedMembers, AdminError> {
928        let reason = leave_group_reason(options.reason.as_deref());
929        let identities = if members.is_empty() {
930            self.all_members_of(group, &reason).await?
931        } else {
932            members
933                .iter()
934                .map(|member| MemberIdentity {
935                    member_id: String::new(),
936                    group_instance_id: Some(member.group_instance_id.clone()),
937                    reason: Some(reason.clone()),
938                    ..Default::default()
939                })
940                .collect()
941        };
942        self.remove_members_from_consumer_group_with_retry(group, &identities, self.retry)
943            .await
944    }
945
946    /// The members of `group` for Kafka's `removeAll` mode, as
947    /// `KafkaAdminClient.getMembersFromGroup` names them.
948    async fn all_members_of(
949        &self,
950        group: &str,
951        reason: &str,
952    ) -> Result<Vec<MemberIdentity>, AdminError> {
953        let description = self
954            .describe_consumer_groups(&[group], DescribeGroupsOptions::builder().build())
955            .await
956            .remove(group)
957            .unwrap_or_else(|| {
958                Err(KafkaError {
959                    code: UNKNOWN_SERVER_ERROR,
960                    name: kafka_error_name(UNKNOWN_SERVER_ERROR),
961                    message: Some(format!("no description for group {group}")),
962                })
963            })
964            .map_err(|error| AdminError::Broker {
965                api: "describeConsumerGroups",
966                code: error.code,
967                name: error.name,
968                message: Some(format!(
969                    "Encounter exception when trying to get members from group: {group}{}",
970                    error
971                        .message
972                        .map(|message| format!(": {message}"))
973                        .unwrap_or_default()
974                )),
975            })?;
976        Ok(description
977            .members
978            .into_iter()
979            .map(|member| {
980                let (member_id, group_instance_id) = match member.group_instance_id {
981                    Some(instance_id) => (String::new(), Some(instance_id)),
982                    None => (member.member_id, None),
983                };
984                MemberIdentity {
985                    member_id,
986                    group_instance_id,
987                    reason: Some(reason.to_owned()),
988                    ..Default::default()
989                }
990            })
991            .collect())
992    }
993
994    async fn remove_members_from_consumer_group_with_retry(
995        &self,
996        group: &str,
997        members: &[MemberIdentity],
998        retry: RetryPolicy,
999    ) -> Result<RemovedMembers, AdminError> {
1000        retry_group_call!(retry, |find| self.leave_group_attempt(group, members, find))
1001    }
1002
1003    async fn leave_group_attempt(
1004        &self,
1005        group: &str,
1006        members: &[MemberIdentity],
1007        find_coordinator: bool,
1008    ) -> RetryAction<RemovedMembers> {
1009        if find_coordinator && let Err(action) = self.find_group_coordinator_attempt(group).await {
1010            return action;
1011        }
1012        let request = MembersOnlyLeaveGroup::new(LeaveGroupRequest {
1013            group_id: group.into(),
1014            members: members.to_vec(),
1015            ..Default::default()
1016        });
1017        match self.conn.send(request).await {
1018            Ok(response) => leave_group_retry_action(response),
1019            Err(error) => connection_failure_action(error),
1020        }
1021    }
1022
1023    group_offsets_method! {
1024        /// Commits explicit start offsets for a share group's partitions with
1025        /// `AlterShareGroupOffsets`, as Apache Kafka's `alterShareGroupOffsets`
1026        /// does. Routes to the group's coordinator with the same retry policy as
1027        /// [`AdminClient::alter_consumer_group_offsets`].
1028        ///
1029        /// A top-level `COORDINATOR_LOAD_IN_PROGRESS` (14) or
1030        /// `REBALANCE_IN_PROGRESS` (27) sends the request again to the same
1031        /// coordinator, and `COORDINATOR_NOT_AVAILABLE` (15) or `NOT_COORDINATOR`
1032        /// (16) finds the coordinator again. Every other top-level code, such as
1033        /// `NON_EMPTY_GROUP` (68), is the error of each requested partition, as
1034        /// Kafka's `AlterShareGroupOffsetsHandler.handleError` puts it in the
1035        /// partition results.
1036        ///
1037        /// # Errors
1038        /// Returns an error when the coordinator lookup, the connection, or the
1039        /// request fails, or at the deadline.
1040        alter_share_group_offsets(offsets: GroupOffsets) => alter_share
1041    }
1042
1043    /// Commits explicit start offsets for a streams group's partitions.
1044    /// Apache Kafka's `KafkaAdminClient.alterStreamsGroupOffsets` delegates to
1045    /// `alterConsumerGroupOffsets` verbatim, so this does too.
1046    ///
1047    /// # Errors
1048    /// See [`AdminClient::alter_consumer_group_offsets`].
1049    pub async fn alter_streams_group_offsets(
1050        &mut self,
1051        group: &str,
1052        offsets: &GroupOffsets,
1053    ) -> Result<Vec<ConsumerGroupOffsetOutcome>, AdminError> {
1054        self.alter_consumer_group_offsets(group, offsets).await
1055    }
1056
1057    /// Reads committed start offsets for a streams group's partitions.
1058    /// Apache Kafka's `KafkaAdminClient.listStreamsGroupOffsets` delegates to
1059    /// `listConsumerGroupOffsets` verbatim, so this does too.
1060    ///
1061    /// # Errors
1062    /// See [`AdminClient::list_consumer_group_offsets`].
1063    pub async fn list_streams_group_offsets(
1064        &mut self,
1065        group: &str,
1066    ) -> Result<GroupOffsets, AdminError> {
1067        self.list_consumer_group_offsets(group).await
1068    }
1069
1070    /// Deletes a share group's committed offsets for the named topics with
1071    /// `DeleteShareGroupOffsets`, as Apache Kafka's
1072    /// `deleteShareGroupOffsets` does. Routes to the group's coordinator with
1073    /// the same retry policy as [`AdminClient::alter_consumer_group_offsets`]:
1074    /// a top-level `COORDINATOR_LOAD_IN_PROGRESS` (14) or
1075    /// `REBALANCE_IN_PROGRESS` (27) retries on the same coordinator, and
1076    /// `COORDINATOR_NOT_AVAILABLE` (15) or `NOT_COORDINATOR` (16) finds the
1077    /// coordinator again.
1078    ///
1079    /// # Errors
1080    /// Returns an error when encoding, transport, or response handling fails.
1081    /// Returns [`AdminError::Broker`] when the coordinator's top-level answer
1082    /// carries a group error code that Kafka does not retry.
1083    pub async fn delete_share_group_offsets(
1084        &mut self,
1085        group: &str,
1086        topics: &[String],
1087    ) -> Result<Vec<(String, Option<KafkaError>)>, AdminError> {
1088        self.delete_share_group_offsets_with_retry(group, topics, self.retry)
1089            .await
1090    }
1091
1092    async fn delete_share_group_offsets_with_retry(
1093        &self,
1094        group: &str,
1095        topics: &[String],
1096        retry: RetryPolicy,
1097    ) -> Result<Vec<(String, Option<KafkaError>)>, AdminError> {
1098        retry_group_call!(retry, |find| {
1099            self.delete_share_group_offsets_attempt(group, topics, find)
1100        })
1101    }
1102
1103    async fn delete_share_group_offsets_attempt(
1104        &self,
1105        group: &str,
1106        topics: &[String],
1107        find_coordinator: bool,
1108    ) -> RetryAction<Vec<(String, Option<KafkaError>)>> {
1109        if find_coordinator && let Err(action) = self.find_group_coordinator_attempt(group).await {
1110            return action;
1111        }
1112        let request = DeleteShareGroupOffsetsRequest {
1113            group_id: group.into(),
1114            topics: topics
1115                .iter()
1116                .map(|topic_name| delete_share_offset_topic(topic_name))
1117                .collect(),
1118            ..Default::default()
1119        };
1120        match self.conn.send(request).await {
1121            Ok(response) => delete_share_group_offsets_retry_action(response),
1122            Err(error) => connection_failure_action(error),
1123        }
1124    }
1125}
1126
1127fn delete_share_offset_topic(topic_name: &str) -> DeleteShareGroupOffsetsRequestTopic {
1128    DeleteShareGroupOffsetsRequestTopic {
1129        topic_name: topic_name.into(),
1130        ..Default::default()
1131    }
1132}
1133
1134/// `COORDINATOR_LOAD_IN_PROGRESS`: the coordinator is loading the group.
1135const COORDINATOR_LOAD_IN_PROGRESS: i16 = 14;
1136/// `COORDINATOR_NOT_AVAILABLE`: no broker coordinates the group now.
1137const COORDINATOR_NOT_AVAILABLE: i16 = 15;
1138/// `NOT_COORDINATOR`: the broker does not coordinate the group.
1139const NOT_COORDINATOR: i16 = 16;
1140
1141/// Whether Kafka's `CoordinatorStrategy.handleError` retries this
1142/// `FindCoordinator` error code.
1143fn is_retriable_find_coordinator_error(code: i16) -> bool {
1144    matches!(
1145        code,
1146        COORDINATOR_LOAD_IN_PROGRESS | COORDINATOR_NOT_AVAILABLE
1147    )
1148}
1149
1150/// Map the result of one `OffsetFetch` attempt to a retry action, as Kafka's
1151/// `ListConsumerGroupOffsetsHandler.handleGroupError` does. 14 retries on the
1152/// same coordinator. 15 and 16 unmap the group, so the next attempt finds the
1153/// coordinator again. Every other result is final.
1154fn offset_fetch_retry_action<T>(result: Result<T, AdminError>) -> RetryAction<T> {
1155    match result {
1156        Err(AdminError::Broker {
1157            code: COORDINATOR_LOAD_IN_PROGRESS,
1158            ..
1159        }) => RetryAction::SameCoordinator(result),
1160        Err(AdminError::Broker {
1161            code: COORDINATOR_NOT_AVAILABLE | NOT_COORDINATOR,
1162            ..
1163        }) => RetryAction::FindCoordinator(result),
1164        result => RetryAction::Done(result),
1165    }
1166}
1167
1168/// `REBALANCE_IN_PROGRESS`: the group is rebalancing.
1169const REBALANCE_IN_PROGRESS: i16 = 27;
1170
1171/// Map one `OffsetCommit` response to a retry action, as Kafka's
1172/// `AlterConsumerGroupOffsetsHandler.handleResponse` does. A partition with 14
1173/// or 27 retries the request on the same coordinator. A partition with 15 or
1174/// 16 unmaps the group, so the next attempt finds the coordinator again. The
1175/// unmap wins when both occur, as the handler returns `ApiResult.unmapped`.
1176/// Every other code is a final per-partition outcome.
1177fn offset_commit_retry_action(
1178    response: OffsetCommitResponse,
1179) -> RetryAction<Vec<ConsumerGroupOffsetOutcome>> {
1180    let outcomes = group_offset_outcomes(response.topics);
1181    let codes = || {
1182        outcomes
1183            .iter()
1184            .filter_map(|outcome| outcome.error.as_ref().map(|error| error.code))
1185    };
1186    if codes().any(|code| matches!(code, COORDINATOR_NOT_AVAILABLE | NOT_COORDINATOR)) {
1187        RetryAction::FindCoordinator(Ok(outcomes))
1188    } else if codes()
1189        .any(|code| matches!(code, COORDINATOR_LOAD_IN_PROGRESS | REBALANCE_IN_PROGRESS))
1190    {
1191        RetryAction::SameCoordinator(Ok(outcomes))
1192    } else {
1193        completed_group_offsets(outcomes)
1194    }
1195}
1196
1197/// Builds an `OffsetDelete` request for the named partitions of `group`.
1198macro_rules! offset_request_topics {
1199    ($topics:expr, $topic_type:ident) => {
1200        $topics
1201            .into_iter()
1202            .map(|(name, partitions)| $topic_type {
1203                name,
1204                partitions,
1205                ..Default::default()
1206            })
1207            .collect()
1208    };
1209}
1210
1211fn offset_delete_request(group: &str, partitions: &[(String, i32)]) -> OffsetDeleteRequest {
1212    let mut topics = BTreeMap::<String, Vec<OffsetDeleteRequestPartition>>::new();
1213    for (topic, partition) in partitions {
1214        topics
1215            .entry(topic.clone())
1216            .or_default()
1217            .push(OffsetDeleteRequestPartition {
1218                partition_index: *partition,
1219                ..Default::default()
1220            });
1221    }
1222    OffsetDeleteRequest {
1223        group_id: group.into(),
1224        topics: offset_request_topics!(topics, OffsetDeleteRequestTopic),
1225        ..Default::default()
1226    }
1227}
1228
1229/// Maps one `OffsetDelete` answer to a retry action, as Kafka's
1230/// `DeleteConsumerGroupOffsetsHandler.handleResponse` does: the top-level
1231/// `error_code` names a group-level failure, and `14` retries on the same
1232/// coordinator while `15` and `16` unmap the group. A final answer becomes one
1233/// [`ConsumerGroupOffsetOutcome`] per requested partition.
1234fn offset_delete_retry_action(
1235    group: &str,
1236    response: OffsetDeleteResponse,
1237) -> RetryAction<Vec<ConsumerGroupOffsetOutcome>> {
1238    if response.error_code != 0 {
1239        let result = Err(AdminError::Broker {
1240            api: "OffsetDelete",
1241            code: response.error_code,
1242            name: kafka_error_name(response.error_code),
1243            message: Some(format!("group={group}")),
1244        });
1245        return group_coordinator_error_action(response.error_code, result);
1246    }
1247    let outcomes = group_offset_outcomes(response.topics);
1248    completed_group_offsets(outcomes)
1249}
1250
1251/// A `LeaveGroup` request that always carries its member list, needing v3.
1252///
1253/// Apache Kafka's `RemoveMembersFromConsumerGroupHandler` builds `LeaveGroup`
1254/// with `LeaveGroupRequest.Builder`, which the client negotiates at v3 or
1255/// above so that the `members` array is not silently dropped by a downgrade.
1256/// Maps one `LeaveGroup` answer to a retry action, as Kafka's
1257/// `RemoveMembersFromConsumerGroupHandler.handleResponse` does. The top-level
1258/// `error_code` is a group-level failure: `14` retries on the same
1259/// coordinator, `15` and `16` unmap the group. A final answer maps each
1260/// answered member, by member id and instance id, to its error.
1261fn leave_group_retry_action(response: LeaveGroupResponse) -> RetryAction<RemovedMembers> {
1262    if response.error_code != 0 {
1263        let result = Err(AdminError::Broker {
1264            api: "LeaveGroup",
1265            code: response.error_code,
1266            name: kafka_error_name(response.error_code),
1267            message: None,
1268        });
1269        return group_coordinator_error_action(response.error_code, result);
1270    }
1271    let members = response
1272        .members
1273        .into_iter()
1274        .map(|member| {
1275            (
1276                RemovedMember {
1277                    member_id: member.member_id,
1278                    group_instance_id: member.group_instance_id,
1279                },
1280                kafka_error_if(member.error_code, None),
1281            )
1282        })
1283        .collect();
1284    RetryAction::Done(Ok(RemovedMembers { members }))
1285}
1286
1287fn group_coordinator_error_action<T>(
1288    error_code: i16,
1289    result: Result<T, AdminError>,
1290) -> RetryAction<T> {
1291    match error_code {
1292        COORDINATOR_LOAD_IN_PROGRESS => RetryAction::SameCoordinator(result),
1293        COORDINATOR_NOT_AVAILABLE | NOT_COORDINATOR => RetryAction::FindCoordinator(result),
1294        _ => RetryAction::Done(result),
1295    }
1296}
1297
1298/// `UNKNOWN_SERVER_ERROR`: the code that Kafka's `ApiError.fromThrowable`
1299/// gives an exception with no Kafka error code.
1300const UNKNOWN_SERVER_ERROR: i16 = -1;
1301/// Kafka's `KafkaAdminClient.DEFAULT_LEAVE_GROUP_REASON`.
1302const DEFAULT_LEAVE_GROUP_REASON: &str = "member was removed by an admin";
1303/// The longest reason that Kafka's `JoinGroupRequest.maybeTruncateReason`
1304/// keeps.
1305const MAX_REASON_CHARS: usize = 255;
1306
1307/// The reason of each removed member: `reason` cut to 255 characters, or
1308/// Kafka's default when it is `None` or empty.
1309fn leave_group_reason(reason: Option<&str>) -> String {
1310    match reason {
1311        Some(reason) if !reason.is_empty() => reason.chars().take(MAX_REASON_CHARS).collect(),
1312        _ => DEFAULT_LEAVE_GROUP_REASON.to_owned(),
1313    }
1314}
1315
1316/// One member to remove, as Apache Kafka's `MemberToRemove` names it: by its
1317/// `group.instance.id`.
1318#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1319pub struct MemberToRemove {
1320    pub group_instance_id: String,
1321}
1322
1323/// The options of [`AdminClient::remove_members_from_consumer_group`], as
1324/// Apache Kafka's `RemoveMembersFromConsumerGroupOptions` holds them.
1325#[derive(Debug, Clone, Default, PartialEq, Eq, bon::Builder)]
1326pub struct RemoveMembersOptions {
1327    /// The reason that the coordinator logs for each member. `None` or empty
1328    /// sends Kafka's default reason.
1329    #[builder(into)]
1330    pub reason: Option<String>,
1331}
1332
1333/// One member of a `LeaveGroup` answer, as the `MemberIdentity` keys of
1334/// Kafka's `RemoveMembersFromConsumerGroupHandler` result name it.
1335#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1336pub struct RemovedMember {
1337    pub member_id: String,
1338    pub group_instance_id: Option<String>,
1339}
1340
1341/// The result of [`AdminClient::remove_members_from_consumer_group`], as
1342/// Apache Kafka's `RemoveMembersFromConsumerGroupResult` holds it.
1343#[derive(Debug, Clone, Default, PartialEq, Eq)]
1344pub struct RemovedMembers {
1345    /// Each member of the `LeaveGroup` answer and its error.
1346    pub members: BTreeMap<RemovedMember, Option<KafkaError>>,
1347}
1348
1349impl RemovedMembers {
1350    /// The result of one requested member, as Kafka's
1351    /// `RemoveMembersFromConsumerGroupResult.memberResult` gives it.
1352    ///
1353    /// # Errors
1354    /// Returns the member's error, or `UNKNOWN_SERVER_ERROR` when the answer
1355    /// does not name the member, as Kafka's `getSubLevelError` gives an
1356    /// `IllegalArgumentException`.
1357    pub fn member_result(&self, member: &MemberToRemove) -> Result<(), KafkaError> {
1358        let key = RemovedMember {
1359            member_id: String::new(),
1360            group_instance_id: Some(member.group_instance_id.clone()),
1361        };
1362        match self.members.get(&key) {
1363            Some(None) => Ok(()),
1364            Some(Some(error)) => Err(error.clone()),
1365            None => Err(KafkaError {
1366                code: UNKNOWN_SERVER_ERROR,
1367                name: kafka_error_name(UNKNOWN_SERVER_ERROR),
1368                message: Some(format!(
1369                    "Member \"{}\" was not included in the removal response",
1370                    member.group_instance_id
1371                )),
1372            }),
1373        }
1374    }
1375}
1376
1377/// Builds an `AlterShareGroupOffsets` request for `group`'s partitions.
1378fn alter_share_group_offsets_request(
1379    group: &str,
1380    offsets: &GroupOffsets,
1381) -> AlterShareGroupOffsetsRequest {
1382    let mut topics = BTreeMap::<String, Vec<AlterShareGroupOffsetsRequestPartition>>::new();
1383    for ((topic, partition), start_offset) in offsets {
1384        topics
1385            .entry(topic.clone())
1386            .or_default()
1387            .push(AlterShareGroupOffsetsRequestPartition {
1388                partition_index: *partition,
1389                start_offset: *start_offset,
1390                ..Default::default()
1391            });
1392    }
1393    AlterShareGroupOffsetsRequest {
1394        group_id: group.into(),
1395        topics: topics
1396            .into_iter()
1397            .map(
1398                |(topic_name, partitions)| AlterShareGroupOffsetsRequestTopic {
1399                    topic_name,
1400                    partitions,
1401                    ..Default::default()
1402                },
1403            )
1404            .collect(),
1405        ..Default::default()
1406    }
1407}
1408
1409fn share_group_coordinator_error_action<T>(
1410    code: i16,
1411    error: AdminError,
1412    otherwise: impl FnOnce(AdminError) -> Result<T, AdminError>,
1413) -> RetryAction<T> {
1414    match code {
1415        COORDINATOR_LOAD_IN_PROGRESS | REBALANCE_IN_PROGRESS => {
1416            RetryAction::SameCoordinator(Err(error))
1417        }
1418        COORDINATOR_NOT_AVAILABLE | NOT_COORDINATOR => RetryAction::FindCoordinator(Err(error)),
1419        _ => RetryAction::Done(otherwise(error)),
1420    }
1421}
1422
1423fn completed_group_offsets<T>(outcomes: T) -> RetryAction<T> {
1424    RetryAction::Done(Ok(outcomes))
1425}
1426
1427fn map_group_topic_rows<T, Rows, P, O>(
1428    topics: impl IntoIterator<Item = T>,
1429    mut rows: impl FnMut(T) -> (String, Rows),
1430    mut outcome: impl FnMut(String, P) -> O,
1431) -> Vec<O>
1432where
1433    Rows: IntoIterator<Item = P>,
1434{
1435    let mut outcomes = Vec::new();
1436    for topic in topics {
1437        let (name, partitions) = rows(topic);
1438        for partition in partitions {
1439            outcomes.push(outcome(name.clone(), partition));
1440        }
1441    }
1442    outcomes
1443}
1444
1445/// Maps one `AlterShareGroupOffsets` answer to a retry action, as Kafka's
1446/// `AlterShareGroupOffsetsHandler.handleResponse` does. A top-level
1447/// `COORDINATOR_LOAD_IN_PROGRESS` or `REBALANCE_IN_PROGRESS` retries on the
1448/// same coordinator, and `COORDINATOR_NOT_AVAILABLE` or `NOT_COORDINATOR`
1449/// finds the coordinator again. Every other top-level code becomes the error
1450/// of each requested partition. Otherwise the answer gives one
1451/// [`ConsumerGroupOffsetOutcome`] for each answered partition.
1452fn alter_share_group_offsets_retry_action(
1453    offsets: &GroupOffsets,
1454    response: AlterShareGroupOffsetsResponse,
1455) -> RetryAction<Vec<ConsumerGroupOffsetOutcome>> {
1456    if response.error_code != 0 {
1457        let error = AdminError::Broker {
1458            api: "AlterShareGroupOffsets",
1459            code: response.error_code,
1460            name: kafka_error_name(response.error_code),
1461            message: response.error_message.clone(),
1462        };
1463        return share_group_coordinator_error_action(response.error_code, error, |_| {
1464            Ok(offsets
1465                .keys()
1466                .map(|(topic, partition)| ConsumerGroupOffsetOutcome {
1467                    topic: topic.clone(),
1468                    partition: *partition,
1469                    error: kafka_error_if(response.error_code, response.error_message.clone()),
1470                })
1471                .collect())
1472        });
1473    }
1474    let outcomes = map_group_topic_rows(
1475        response.responses,
1476        |topic| (topic.topic_name, topic.partitions),
1477        |topic, partition| ConsumerGroupOffsetOutcome {
1478            topic,
1479            partition: partition.partition_index,
1480            error: kafka_error_if(partition.error_code, partition.error_message),
1481        },
1482    );
1483    completed_group_offsets(outcomes)
1484}
1485
1486/// Maps one `DeleteShareGroupOffsets` answer to a retry action, as Kafka's
1487/// `DeleteShareGroupOffsetsHandler.handleResponse` does. A top-level
1488/// `COORDINATOR_LOAD_IN_PROGRESS` or `REBALANCE_IN_PROGRESS` retries on the
1489/// same coordinator, `COORDINATOR_NOT_AVAILABLE` or `NOT_COORDINATOR` finds
1490/// the coordinator again, and every other top-level code fails the call. A
1491/// final answer gives one `(topic, error)` pair for each answered topic.
1492fn delete_share_group_offsets_retry_action(
1493    response: DeleteShareGroupOffsetsResponse,
1494) -> RetryAction<Vec<(String, Option<KafkaError>)>> {
1495    if response.error_code != 0 {
1496        let error = AdminError::Broker {
1497            api: "DeleteShareGroupOffsets",
1498            code: response.error_code,
1499            name: kafka_error_name(response.error_code),
1500            message: response.error_message,
1501        };
1502        return share_group_coordinator_error_action(response.error_code, error, Err);
1503    }
1504    let outcomes = map_group_topic_rows(
1505        response.responses,
1506        |topic| {
1507            (
1508                topic.topic_name,
1509                iter::once((topic.error_code, topic.error_message)),
1510            )
1511        },
1512        |topic, (code, message)| (topic, kafka_error_if(code, message)),
1513    );
1514    completed_group_offsets(outcomes)
1515}
1516
1517/// The lowest `ListGroups` version that carries the filters of `options`, as
1518/// Kafka's `ListGroupsRequest.Builder.build` requires it.
1519///
1520/// `states_filter` needs v4. `types_filter` needs v5, except when it holds
1521/// `Classic` and `Consumer` at most, with `Classic`. A broker below v5 lists
1522/// only classic groups, so the filter can be omitted. The request encoder
1523/// omits `types_filter` below v5.
1524fn list_groups_min_version(options: &ListGroupsOptions) -> i16 {
1525    let types_need_v5 = !options.types.is_empty()
1526        && (!options.types.contains(&GroupType::Classic)
1527            || options
1528                .types
1529                .iter()
1530                .any(|group_type| !matches!(group_type, GroupType::Classic | GroupType::Consumer)));
1531    if types_need_v5 {
1532        5
1533    } else if options.group_states.is_empty() {
1534        list_groups_request::MIN_VERSION
1535    } else {
1536        4
1537    }
1538}
1539
1540/// Sends `ListGroups` to one broker until it answers, as one Kafka
1541/// `listGroups` node call does.
1542///
1543/// `COORDINATOR_LOAD_IN_PROGRESS`, `COORDINATOR_NOT_AVAILABLE`, a failed
1544/// connection and a lost connection make the call try again. A rejected
1545/// authentication does not, because Kafka's `Call.fail` does not retry an
1546/// `AuthenticationException`.
1547///
1548/// `retry.timeout` after `start` is the deadline of the call. Each attempt
1549/// (the TCP connection, the TLS and SASL handshakes, and the `ListGroups`
1550/// request) gets the time that remains. When the deadline passes, the call
1551/// fails with `REQUEST_TIMED_OUT` (7), as Kafka's `Call.fail` gives a
1552/// `TimeoutException` for a call past its deadline.
1553async fn list_groups_on_broker(
1554    broker: &MetadataResponseBroker,
1555    options: ConnectionOptions,
1556    request: ListGroupsRequest,
1557    min_version: i16,
1558    start: Instant,
1559    retry: RetryPolicy,
1560) -> Result<Vec<ListedGroup>, KafkaError> {
1561    let host_port = format_host_port(&broker.host, broker.port);
1562    let deadline = start + retry.timeout;
1563    let mut backoff = retry.initial_backoff;
1564    let mut connection = None;
1565    let mut attempts = 0_u32;
1566    let mut last = None;
1567    while Instant::now() < deadline {
1568        attempts += 1;
1569        let attempt = list_groups_attempt(
1570            &mut connection,
1571            &host_port,
1572            &options,
1573            request.clone(),
1574            min_version,
1575        );
1576        match timeout_at(deadline, attempt).await {
1577            Ok(RetryAction::Done(result)) => {
1578                return result.map_err(|error| list_groups_kafka_error(&error));
1579            }
1580            Ok(RetryAction::SameCoordinator(result) | RetryAction::FindCoordinator(result)) => {
1581                match result {
1582                    Ok(groups) => return Ok(groups),
1583                    Err(error) => last = Some(error),
1584                }
1585            }
1586            Err(_) => break,
1587        }
1588        // Kafka's `Call.fail` stops after `retries` retries with a
1589        // `TimeoutException`.
1590        if attempts > retry.max_retries {
1591            break;
1592        }
1593        tracing::debug!(
1594            node_id = broker.node_id,
1595            "ListGroups got a retriable error; retrying"
1596        );
1597        sleep_until(deadline.min(Instant::now() + backoff)).await;
1598        backoff = backoff.saturating_mul(2).min(retry.max_backoff);
1599    }
1600    Err(list_groups_timeout(attempts, last.as_ref()))
1601}
1602
1603/// The `REQUEST_TIMED_OUT` error of a `ListGroups` node call past its
1604/// deadline. The message names the attempts and the last retriable error, as
1605/// the `TimeoutException` of Kafka's `Call.handleTimeoutFailure` holds the
1606/// attempt count and the cause.
1607fn list_groups_timeout(attempts: u32, last: Option<&AdminError>) -> KafkaError {
1608    const REQUEST_TIMED_OUT: i16 = 7;
1609    let cause = last
1610        .map(|error| format!("; last error: {error}"))
1611        .unwrap_or_default();
1612    KafkaError {
1613        code: REQUEST_TIMED_OUT,
1614        name: kafka_error_name(REQUEST_TIMED_OUT),
1615        message: Some(format!(
1616            "ListGroups timed out after {attempts} attempt(s){cause}"
1617        )),
1618    }
1619}
1620
1621/// One `ListGroups` attempt on one broker. The attempt connects first when
1622/// `connection` is empty, and empties it after a connection error.
1623async fn list_groups_attempt(
1624    connection: &mut Option<Connection>,
1625    host_port: &str,
1626    options: &ConnectionOptions,
1627    request: ListGroupsRequest,
1628    min_version: i16,
1629) -> RetryAction<Vec<ListedGroup>> {
1630    let current = match connection {
1631        Some(current) => current,
1632        None => match AdminClient::connect_one(host_port, options.clone()).await {
1633            Ok(new) => connection.insert(new),
1634            Err(error) if error.is_authentication_failure() => {
1635                return RetryAction::Done(Err(error));
1636            }
1637            Err(error) => return RetryAction::SameCoordinator(Err(error)),
1638        },
1639    };
1640    match send_connection_at_least(current, request, min_version).await {
1641        Ok(response) => match response.error_code {
1642            0 => RetryAction::Done(Ok(response.groups)),
1643            code => {
1644                let error = Err(AdminError::Broker {
1645                    api: "ListGroups",
1646                    code,
1647                    name: kafka_error_name(code),
1648                    message: None,
1649                });
1650                if matches!(
1651                    code,
1652                    COORDINATOR_LOAD_IN_PROGRESS | COORDINATOR_NOT_AVAILABLE
1653                ) {
1654                    RetryAction::SameCoordinator(error)
1655                } else {
1656                    RetryAction::Done(error)
1657                }
1658            }
1659        },
1660        Err(error) if AdminClient::is_retriable_transport_error(&error) => {
1661            *connection = None;
1662            RetryAction::SameCoordinator(Err(error.into()))
1663        }
1664        Err(error) => RetryAction::Done(Err(error.into())),
1665    }
1666}
1667
1668/// The Kafka error code of a `ListGroups` failure on one broker, as Kafka's
1669/// `Errors.forException` maps the exception class.
1670///
1671/// A rejected authentication maps as its Kafka exception class does:
1672/// `UnsupportedSaslMechanismException` to 33, `IllegalSaslStateException` to
1673/// 34, `SaslAuthenticationException` to 58, and `SslAuthenticationException`
1674/// to 40, the code of its superclass `InvalidConfigurationException`.
1675pub(crate) fn list_groups_kafka_error(error: &AdminError) -> KafkaError {
1676    const UNKNOWN_SERVER_ERROR: i16 = -1;
1677    const REQUEST_TIMED_OUT: i16 = 7;
1678    const NETWORK_EXCEPTION: i16 = 13;
1679    const UNSUPPORTED_SASL_MECHANISM: i16 = 33;
1680    const ILLEGAL_SASL_STATE: i16 = 34;
1681    const UNSUPPORTED_VERSION: i16 = 35;
1682    const INVALID_CONFIG: i16 = 40;
1683    const SASL_AUTHENTICATION_FAILED: i16 = 58;
1684    let (code, message) = match error {
1685        AdminError::Broker { code, message, .. } => (*code, message.clone()),
1686        AdminError::Transport(ClientError::IncompatibleVersion { .. }) => {
1687            (UNSUPPORTED_VERSION, Some(error.to_string()))
1688        }
1689        AdminError::Transport(ClientError::Timeout(_)) => {
1690            (REQUEST_TIMED_OUT, Some(error.to_string()))
1691        }
1692        AdminError::Transport(
1693            ClientError::Connect { .. } | ClientError::Disconnected | ClientError::Io(_),
1694        ) => (NETWORK_EXCEPTION, Some(error.to_string())),
1695        AdminError::Transport(ClientError::Authentication { source, .. }) => {
1696            let code = match source {
1697                AuthenticationError::Tls(_) => INVALID_CONFIG,
1698                AuthenticationError::Sasl(SaslAuthenticationError::UnsupportedMechanism(_)) => {
1699                    UNSUPPORTED_SASL_MECHANISM
1700                }
1701                AuthenticationError::Sasl(SaslAuthenticationError::IllegalState(_)) => {
1702                    ILLEGAL_SASL_STATE
1703                }
1704                AuthenticationError::Sasl(SaslAuthenticationError::Failed(_)) => {
1705                    SASL_AUTHENTICATION_FAILED
1706                }
1707            };
1708            (code, Some(error.to_string()))
1709        }
1710        _ => (UNKNOWN_SERVER_ERROR, Some(error.to_string())),
1711    };
1712    KafkaError {
1713        code,
1714        name: kafka_error_name(code),
1715        message,
1716    }
1717}
1718
1719/// Builds the admin `OffsetFetch` request for all topics of `group`.
1720///
1721/// The request fills the v2 to v7 fields (`group_id`, `topics`) and the v8+
1722/// `groups` array, so it is valid at each negotiated version. Kafka's
1723/// `OffsetFetchRequest.Builder.maybeDowngrade` moves the group into the v2 to
1724/// v7 fields in the same way.
1725fn offset_fetch_group(group: &str) -> OffsetFetchRequestGroup {
1726    OffsetFetchRequestGroup {
1727        group_id: group.into(),
1728        member_id: None,
1729        member_epoch: -1,
1730        topics: None,
1731        ..Default::default()
1732    }
1733}
1734
1735fn offset_fetch_request(group: &str) -> AllTopicsOffsetFetch {
1736    AllTopicsOffsetFetch::new(OffsetFetchRequest {
1737        group_id: group.into(),
1738        topics: None,
1739        groups: vec![offset_fetch_group(group)],
1740        require_stable: false,
1741        ..Default::default()
1742    })
1743}
1744
1745/// Reads the committed offsets of `group` from an `OffsetFetch` response.
1746///
1747/// v8 and v9 put the group in `groups`. v2 to v7 put the group error code and
1748/// the topics at the top level, which Kafka's `OffsetFetchResponse.group`
1749/// reads in the same way. As in Kafka's
1750/// `ListConsumerGroupOffsetsHandler.handleResponse`, a partition with an error
1751/// code gives no row.
1752fn committed_offsets(
1753    group: &str,
1754    response: OffsetFetchResponse,
1755) -> Result<GroupOffsets, AdminError> {
1756    let (error_code, rows): (i16, Vec<(String, i32, i64, i16)>) = if response.groups.is_empty() {
1757        (
1758            response.error_code,
1759            response
1760                .topics
1761                .into_iter()
1762                .flat_map(offset_fetch_topic_rows)
1763                .collect(),
1764        )
1765    } else {
1766        let mut error_code = 0;
1767        let mut rows = Vec::new();
1768        for entry in response
1769            .groups
1770            .into_iter()
1771            .filter(|entry| entry.group_id == group)
1772        {
1773            error_code = entry.error_code;
1774            for topic in entry.topics {
1775                rows.extend(offset_fetch_topic_rows(topic));
1776            }
1777        }
1778        (error_code, rows)
1779    };
1780    if error_code != 0 {
1781        return Err(AdminError::Broker {
1782            api: "OffsetFetch",
1783            code: error_code,
1784            name: kafka_error_name(error_code),
1785            message: Some(format!("group={group}")),
1786        });
1787    }
1788    Ok(rows
1789        .into_iter()
1790        .filter_map(|(topic, partition, offset, partition_error)| {
1791            if partition_error != 0 {
1792                tracing::warn!(
1793                    topic,
1794                    partition,
1795                    error_code = partition_error,
1796                    "skipping the committed offset of a partition with an error"
1797                );
1798                None
1799            } else if offset < 0 {
1800                None
1801            } else {
1802                Some(((topic, partition), offset))
1803            }
1804        })
1805        .collect())
1806}
1807
1808trait IntoOffsetFetchRow {
1809    fn into_offset_fetch_row(self, topic: &str) -> (String, i32, i64, i16);
1810}
1811
1812macro_rules! impl_offset_fetch_row {
1813    ($partition:ty) => {
1814        impl IntoOffsetFetchRow for $partition {
1815            fn into_offset_fetch_row(self, topic: &str) -> (String, i32, i64, i16) {
1816                (
1817                    topic.to_owned(),
1818                    self.partition_index,
1819                    self.committed_offset,
1820                    self.error_code,
1821                )
1822            }
1823        }
1824    };
1825}
1826
1827impl_offset_fetch_row!(OffsetFetchResponsePartition);
1828impl_offset_fetch_row!(OffsetFetchResponsePartitions);
1829
1830trait IntoOffsetFetchTopics {
1831    type Partition: IntoOffsetFetchRow;
1832
1833    fn into_offset_fetch_parts(self) -> (String, Vec<Self::Partition>);
1834}
1835
1836macro_rules! impl_offset_fetch_topics {
1837    ($topics:ty, $partition:ty) => {
1838        impl IntoOffsetFetchTopics for $topics {
1839            type Partition = $partition;
1840
1841            fn into_offset_fetch_parts(self) -> (String, Vec<Self::Partition>) {
1842                (self.name, self.partitions)
1843            }
1844        }
1845    };
1846}
1847
1848impl_offset_fetch_topics!(OffsetFetchResponseTopic, OffsetFetchResponsePartition);
1849impl_offset_fetch_topics!(OffsetFetchResponseTopics, OffsetFetchResponsePartitions);
1850
1851fn offset_fetch_topic_rows<T: IntoOffsetFetchTopics>(topic: T) -> Vec<(String, i32, i64, i16)> {
1852    let (name, partitions) = topic.into_offset_fetch_parts();
1853    partitions
1854        .into_iter()
1855        .map(|partition| partition.into_offset_fetch_row(&name))
1856        .collect()
1857}
1858
1859fn offset_commit_request(group: &str, offsets: &GroupOffsets) -> TopicNameOffsetCommit {
1860    let mut topics = BTreeMap::<String, Vec<OffsetCommitRequestPartition>>::new();
1861    for ((topic, partition), offset) in offsets {
1862        topics
1863            .entry(topic.clone())
1864            .or_default()
1865            .push(OffsetCommitRequestPartition {
1866                partition_index: *partition,
1867                committed_offset: *offset,
1868                committed_leader_epoch: -1,
1869                // Kafka's `OffsetAndMetadata` metadata defaults to "", which
1870                // is also the schema default this row takes.
1871                ..Default::default()
1872            });
1873    }
1874    TopicNameOffsetCommit::new(OffsetCommitRequest {
1875        group_id: group.into(),
1876        generation_id_or_member_epoch: -1,
1877        member_id: String::new(),
1878        topics: offset_request_topics!(topics, OffsetCommitRequestTopic),
1879        ..Default::default()
1880    })
1881}
1882
1883#[cfg(test)]
1884mod tests {
1885    use std::{
1886        fmt::Debug,
1887        net::SocketAddr,
1888        sync::{
1889            Arc, Mutex,
1890            atomic::{AtomicUsize, Ordering},
1891        },
1892        time::Duration,
1893    };
1894
1895    use assert2::assert;
1896    use bytes::{Buf, BytesMut};
1897    use krabka_client_core::{
1898        AuthenticationError, ClientError, MockBroker, MockReply, MockSaslAnswer,
1899        SaslAuthenticationError,
1900        mock_test_support::offset_commit_response,
1901        security::{ClientSecurity, SaslCredentials},
1902    };
1903    use krabka_protocol::{
1904        Decode, Encode, ProtocolRequest,
1905        owned::{
1906            alter_share_group_offsets_response::{
1907                AlterShareGroupOffsetsResponsePartition, AlterShareGroupOffsetsResponseTopic,
1908            },
1909            api_versions_request,
1910            api_versions_response::{ApiVersion, ApiVersionsResponse},
1911            delete_share_group_offsets_response::DeleteShareGroupOffsetsResponseTopic,
1912            find_coordinator_request,
1913            find_coordinator_response::FindCoordinatorResponse,
1914            leave_group_response::MemberResponse,
1915            list_groups_response::ListGroupsResponse,
1916            metadata_request,
1917            metadata_response::MetadataResponse,
1918            offset_commit_request,
1919            offset_commit_response::{
1920                OffsetCommitResponse, OffsetCommitResponsePartition, OffsetCommitResponseTopic,
1921            },
1922            offset_delete_response::{OffsetDeleteResponsePartition, OffsetDeleteResponseTopic},
1923            offset_fetch_request,
1924            offset_fetch_response::{
1925                OffsetFetchResponseGroup, OffsetFetchResponsePartition,
1926                OffsetFetchResponsePartitions, OffsetFetchResponseTopic, OffsetFetchResponseTopics,
1927            },
1928            sasl_handshake_request,
1929        },
1930    };
1931    use tokio::{net::TcpListener, runtime, task::JoinHandle, time::timeout};
1932
1933    use super::*;
1934
1935    fn test_group_offsets() -> GroupOffsets {
1936        maplit::btreemap! { ("orders".into(), 2) => 41 }
1937    }
1938
1939    #[test]
1940    fn offset_reset_builds_admin_commit() {
1941        let offsets = test_group_offsets();
1942        let request = offset_commit_request("worker", &offsets);
1943        let expected = TopicNameOffsetCommit::new(commit_request_for_test("worker", -1, -1));
1944        assert!(request == expected);
1945    }
1946
1947    fn commit_request_for_test(
1948        group_id: &str,
1949        generation_id_or_member_epoch: i32,
1950        committed_leader_epoch: i32,
1951    ) -> OffsetCommitRequest {
1952        OffsetCommitRequest {
1953            group_id: group_id.into(),
1954            generation_id_or_member_epoch,
1955            topics: vec![OffsetCommitRequestTopic {
1956                name: "orders".into(),
1957                partitions: vec![OffsetCommitRequestPartition {
1958                    partition_index: 2,
1959                    committed_offset: 41,
1960                    committed_leader_epoch,
1961                    ..Default::default()
1962                }],
1963                ..Default::default()
1964            }],
1965            ..Default::default()
1966        }
1967    }
1968
1969    fn encode(response: &impl Encode, version: i16, flexible: bool) -> Vec<u8> {
1970        let mut bytes = BytesMut::new();
1971        if flexible {
1972            bytes.extend_from_slice(&[0]);
1973        }
1974        response.encode(&mut bytes, version).unwrap();
1975        bytes.to_vec()
1976    }
1977
1978    fn encode_flexible(response: &impl Encode, version: i16) -> Vec<u8> {
1979        encode(response, version, true)
1980    }
1981
1982    fn broker_port(addr: SocketAddr) -> i32 {
1983        i32::from(addr.port())
1984    }
1985
1986    struct MockBrokerAddress {
1987        node_id: i32,
1988        host: String,
1989        port: i32,
1990    }
1991
1992    fn mock_broker_address(addr: SocketAddr, node_id: i32) -> MockBrokerAddress {
1993        MockBrokerAddress {
1994            node_id,
1995            host: addr.ip().to_string(),
1996            port: broker_port(addr),
1997        }
1998    }
1999
2000    macro_rules! broker_fields {
2001        ($type:ident, $broker:expr) => {
2002            $type {
2003                node_id: $broker.node_id,
2004                host: $broker.host,
2005                port: $broker.port,
2006                ..Default::default()
2007            }
2008        };
2009    }
2010
2011    fn mock_metadata_broker(addr: SocketAddr, node_id: i32) -> MetadataResponseBroker {
2012        let broker = mock_broker_address(addr, node_id);
2013        broker_fields!(MetadataResponseBroker, broker)
2014    }
2015
2016    async fn connect_admin(addr: SocketAddr, message: &'static str) -> AdminClient {
2017        AdminClient::connect(&[addr.to_string()])
2018            .await
2019            .expect(message)
2020    }
2021
2022    fn mock_coordinator_response(addr: SocketAddr, error_code: i16) -> FindCoordinatorResponse {
2023        let broker = mock_broker_address(addr, 2);
2024        let mut response = broker_fields!(FindCoordinatorResponse, broker);
2025        response.error_code = error_code;
2026        response
2027    }
2028
2029    fn incompatible_version(error: AdminError, case: &str) -> (i16, i16, i16, i16, i16) {
2030        match error {
2031            AdminError::Transport(ClientError::IncompatibleVersion {
2032                api_key,
2033                broker_min,
2034                broker_max,
2035                client_min,
2036                client_max,
2037            }) => (api_key, broker_min, broker_max, client_min, client_max),
2038            other => panic!("case {case}: unexpected error {other:?}"),
2039        }
2040    }
2041
2042    async fn with_incompatible_version<T>(
2043        case: &'static str,
2044        result: impl Future<Output = Result<T, AdminError>>,
2045    ) -> Result<T, (i16, i16, i16, i16, i16)> {
2046        result
2047            .await
2048            .map_err(|error| incompatible_version(error, case))
2049    }
2050
2051    /// The `OffsetCommit` and `OffsetFetch` version ranges that a mock broker
2052    /// advertises.
2053    #[derive(Clone, Copy)]
2054    struct GroupRanges {
2055        offset_commit: (i16, i16),
2056        offset_fetch: (i16, i16),
2057    }
2058
2059    impl GroupRanges {
2060        fn offset_commit(offset_commit: (i16, i16)) -> Self {
2061            Self {
2062                offset_commit,
2063                offset_fetch: (2, 10),
2064            }
2065        }
2066
2067        fn offset_fetch(offset_fetch: (i16, i16)) -> Self {
2068            Self {
2069                offset_commit: (2, 9),
2070                offset_fetch,
2071            }
2072        }
2073    }
2074
2075    fn encode_api_versions(api_keys: Vec<ApiVersion>) -> Vec<u8> {
2076        encode(
2077            &ApiVersionsResponse {
2078                api_keys,
2079                ..Default::default()
2080            },
2081            0,
2082            false,
2083        )
2084    }
2085
2086    macro_rules! api_version_entry {
2087        ($key:expr, $min:expr, $max:expr) => {
2088            ApiVersion {
2089                api_key: $key,
2090                min_version: $min,
2091                max_version: $max,
2092                ..Default::default()
2093            }
2094        };
2095    }
2096
2097    /// An `ApiVersions` response that advertises `ranges`.
2098    fn api_versions(ranges: GroupRanges) -> Vec<u8> {
2099        encode_api_versions(vec![
2100            api_version_entry!(api_versions_request::API_KEY, 0, 0),
2101            api_version_entry!(find_coordinator_request::API_KEY, 0, 0),
2102            api_version_entry!(
2103                offset_commit_request::API_KEY,
2104                ranges.offset_commit.0,
2105                ranges.offset_commit.1
2106            ),
2107            api_version_entry!(
2108                offset_fetch_request::API_KEY,
2109                ranges.offset_fetch.0,
2110                ranges.offset_fetch.1
2111            ),
2112            api_version_entry!(metadata_request::API_KEY, 13, 13),
2113        ])
2114    }
2115
2116    /// Decodes a request body of type `R` behind its request header.
2117    fn decode_request<R: ProtocolRequest + for<'de> Decode<'de>>(
2118        mut body: &[u8],
2119        version: i16,
2120    ) -> R {
2121        let client_id_len = body.get_i16();
2122        body.advance(usize::try_from(client_id_len).expect("client id length"));
2123        if version >= R::FLEXIBLE_MIN {
2124            body.advance(1);
2125        }
2126        R::decode(&mut body, version).expect("request decodes")
2127    }
2128
2129    /// A bootstrap broker that sends every group RPC to `coordinator`.
2130    async fn bootstrap_for(
2131        coordinator: &MockBroker,
2132        ranges: GroupRanges,
2133        group_rpcs: Arc<AtomicUsize>,
2134    ) -> MockBroker {
2135        let coordinator_addr = coordinator.addr;
2136        MockBroker::start(move |api_key, version, _, _| match api_key {
2137            api_versions_request::API_KEY => Some(api_versions(ranges)),
2138            find_coordinator_request::API_KEY => Some(encode(
2139                &mock_coordinator_response(coordinator_addr, 0),
2140                version,
2141                false,
2142            )),
2143            offset_commit_request::API_KEY => {
2144                group_rpcs.fetch_add(1, Ordering::SeqCst);
2145                Some(encode(&OffsetCommitResponse::default(), version, true))
2146            }
2147            offset_fetch_request::API_KEY => {
2148                group_rpcs.fetch_add(1, Ordering::SeqCst);
2149                Some(encode(&OffsetFetchResponse::default(), version, true))
2150            }
2151            metadata_request::API_KEY => Some(empty_metadata_reply(version)),
2152            _ => None,
2153        })
2154        .await
2155        .unwrap()
2156    }
2157
2158    struct GroupAdminConnection {
2159        bootstrap: MockBroker,
2160        admin: AdminClient,
2161    }
2162
2163    async fn connect_group_admin(
2164        coordinator: &MockBroker,
2165        ranges: GroupRanges,
2166    ) -> GroupAdminConnection {
2167        let bootstrap = bootstrap_for(coordinator, ranges, Arc::default()).await;
2168        let admin = connect_admin(bootstrap.addr, "admin connects").await;
2169        GroupAdminConnection { bootstrap, admin }
2170    }
2171
2172    fn stop_group_brokers(bootstrap: MockBroker, coordinator: MockBroker) {
2173        bootstrap.stop();
2174        coordinator.stop();
2175    }
2176
2177    fn record_group_request<Request>(
2178        requests: &Arc<Mutex<Vec<(i16, Request)>>>,
2179        version: i16,
2180        request: Request,
2181    ) {
2182        requests
2183            .lock()
2184            .expect("requests lock")
2185            .push((version, request));
2186    }
2187
2188    struct GroupVersionAssertion<Request, Output> {
2189        name: &'static str,
2190        requests: Vec<(i16, Request)>,
2191        result: Output,
2192        expected_requests: Vec<(i16, Request)>,
2193        expected_result: Output,
2194    }
2195
2196    fn assert_group_version_case<Request, Output>(case: GroupVersionAssertion<Request, Output>)
2197    where
2198        Request: Debug + PartialEq,
2199        Output: Debug + PartialEq,
2200    {
2201        assert!(
2202            (case.requests, case.result) == (case.expected_requests, case.expected_result),
2203            "case {}",
2204            case.name
2205        );
2206    }
2207
2208    struct ExpectedGroupVersionCase<Request, Output> {
2209        name: &'static str,
2210        ranges: GroupRanges,
2211        requests: Vec<(i16, Request)>,
2212        result: Output,
2213    }
2214
2215    fn expected_group_version_case<Request, Output>(
2216        name: &'static str,
2217        ranges: GroupRanges,
2218        requests: Vec<(i16, Request)>,
2219        result: Output,
2220    ) -> ExpectedGroupVersionCase<Request, Output> {
2221        ExpectedGroupVersionCase {
2222            name,
2223            ranges,
2224            requests,
2225            result,
2226        }
2227    }
2228
2229    fn incompatible_group_version<T>(
2230        api_key: i16,
2231        broker_min: i16,
2232        broker_max: i16,
2233    ) -> Result<T, (i16, i16, i16, i16, i16)> {
2234        Err((api_key, broker_min, broker_max, 2, 9))
2235    }
2236
2237    async fn run_group_version_case<Request, Output, Respond, Action, Fut>(
2238        case: ExpectedGroupVersionCase<Request, Output>,
2239        api_key: i16,
2240        respond: Respond,
2241        action: Action,
2242    ) where
2243        Request:
2244            ProtocolRequest + for<'de> Decode<'de> + Clone + PartialEq + Debug + Send + 'static,
2245        Output: PartialEq + Debug,
2246        Respond: Fn(&Request, i16) -> Vec<u8> + Copy + Send + Sync + 'static,
2247        Action: FnOnce(&'static str, AdminClient) -> Fut,
2248        Fut: Future<Output = Output>,
2249    {
2250        let ranges = case.ranges;
2251        let requests = Arc::new(Mutex::new(Vec::new()));
2252        let recorded = Arc::clone(&requests);
2253        let metadata_requests = Arc::new(AtomicUsize::new(0));
2254        let metadata_count = Arc::clone(&metadata_requests);
2255        let coordinator =
2256            MockBroker::start(move |request_api, version, _, body| match request_api {
2257                api_versions_request::API_KEY => Some(api_versions(ranges)),
2258                request_api if request_api == api_key => {
2259                    let request = decode_request(body, version);
2260                    let response = respond(&request, version);
2261                    record_group_request(&recorded, version, request);
2262                    Some(response)
2263                }
2264                metadata_request::API_KEY => {
2265                    counted_reply(&metadata_count, Some(empty_metadata_reply(version)))
2266                }
2267                _ => None,
2268            })
2269            .await
2270            .unwrap();
2271        let GroupAdminConnection { bootstrap, admin } =
2272            connect_group_admin(&coordinator, ranges).await;
2273        let result = action(case.name, admin).await;
2274        stop_group_brokers(bootstrap, coordinator);
2275        let requests = requests.lock().expect("requests lock").clone();
2276        assert!(
2277            metadata_requests.load(Ordering::SeqCst) == 0,
2278            "case {}",
2279            case.name
2280        );
2281        assert_group_version_case(GroupVersionAssertion {
2282            name: case.name,
2283            requests,
2284            result,
2285            expected_requests: case.requests,
2286            expected_result: case.result,
2287        });
2288    }
2289
2290    /// The negotiated `OffsetCommit` version and the result of
2291    /// `alter_consumer_group_offsets`, with a version error as its ranges.
2292    type CommitResult = Result<Vec<ConsumerGroupOffsetOutcome>, (i16, i16, i16, i16, i16)>;
2293
2294    /// Apache Kafka's `AlterConsumerGroupOffsetsHandler` builds `OffsetCommit`
2295    /// with `OffsetCommitRequest.Builder.forTopicNames`, which caps the version
2296    /// at 9. The request names each topic at every negotiated version.
2297    fn commit_group_version_cases()
2298    -> Vec<ExpectedGroupVersionCase<OffsetCommitRequest, CommitResult>> {
2299        let committed = Ok(successful_group_offset_outcome());
2300        let commit_case = |name, version_range, requests, result| {
2301            expected_group_version_case(
2302                name,
2303                GroupRanges::offset_commit(version_range),
2304                requests,
2305                result,
2306            )
2307        };
2308        let sent_request = |version| vec![(version, commit_request_for_test("workers", -1, -1))];
2309        vec![
2310            commit_case(
2311                "coordinator stops at v7",
2312                (2, 7),
2313                sent_request(7),
2314                committed.clone(),
2315            ),
2316            commit_case(
2317                "coordinator stops at v9",
2318                (2, 9),
2319                sent_request(9),
2320                committed.clone(),
2321            ),
2322            commit_case(
2323                "coordinator supports v10",
2324                (2, 10),
2325                sent_request(9),
2326                committed,
2327            ),
2328            commit_case(
2329                "coordinator supports only v10",
2330                (10, 10),
2331                Vec::new(),
2332                incompatible_group_version(offset_commit_request::API_KEY, 10, 10),
2333            ),
2334        ]
2335    }
2336
2337    #[yare::parameterized(
2338        coordinator_stops_at_v7 = { 0 },
2339        coordinator_stops_at_v9 = { 1 },
2340        coordinator_supports_v10 = { 2 },
2341        coordinator_only_supports_v10 = { 3 },
2342    )]
2343    fn alter_consumer_group_offsets_sends_offset_commit_by_topic_name(index: usize) {
2344        let case = commit_group_version_cases().remove(index);
2345        block_on_test(run_group_version_case(
2346            case,
2347            offset_commit_request::API_KEY,
2348            |request, version| {
2349                encode(
2350                    &offset_commit_response(request, |_| 0),
2351                    version,
2352                    version >= offset_commit_request::FLEXIBLE_MIN,
2353                )
2354            },
2355            commit_group_offsets_by_topic,
2356        ));
2357    }
2358
2359    async fn commit_group_offsets_by_topic(
2360        name: &'static str,
2361        mut admin: AdminClient,
2362    ) -> CommitResult {
2363        let offsets = test_group_offsets();
2364        Box::pin(with_incompatible_version(
2365            name,
2366            admin.alter_consumer_group_offsets("workers", &offsets),
2367        ))
2368        .await
2369    }
2370
2371    macro_rules! offset_fetch_topics {
2372        ($topic:ident, $partition:ident, $rows:expr) => {
2373            ($rows)
2374                .iter()
2375                .map(
2376                    |(name, partition_index, committed_offset, error_code)| $topic {
2377                        name: (*name).into(),
2378                        partitions: vec![$partition {
2379                            partition_index: *partition_index,
2380                            committed_offset: *committed_offset,
2381                            error_code: *error_code,
2382                            ..Default::default()
2383                        }],
2384                        ..Default::default()
2385                    },
2386                )
2387                .collect()
2388        };
2389    }
2390
2391    /// The `OffsetFetch` answer of the mock coordinator at `version`: one
2392    /// committed offset, one partition with no committed offset, and one
2393    /// partition with an error code. v2 to v7 put the topics at the top level.
2394    fn offset_fetch_response(version: i16) -> OffsetFetchResponse {
2395        let rows = [
2396            ("orders", 2, 41, 0),
2397            ("orders", 3, -1, 0),
2398            ("payments", 0, 5, 3),
2399        ];
2400        if version < 8 {
2401            OffsetFetchResponse {
2402                topics: offset_fetch_topics!(
2403                    OffsetFetchResponseTopic,
2404                    OffsetFetchResponsePartition,
2405                    &rows
2406                ),
2407                ..Default::default()
2408            }
2409        } else {
2410            OffsetFetchResponse {
2411                groups: vec![OffsetFetchResponseGroup {
2412                    group_id: "workers".into(),
2413                    topics: offset_fetch_topics!(
2414                        OffsetFetchResponseTopics,
2415                        OffsetFetchResponsePartitions,
2416                        &rows
2417                    ),
2418                    ..Default::default()
2419                }],
2420                ..Default::default()
2421            }
2422        }
2423    }
2424
2425    /// The negotiated `OffsetFetch` version and the result of
2426    /// `list_consumer_group_offsets`, with a version error as its ranges.
2427    type FetchResult = Result<GroupOffsets, (i16, i16, i16, i16, i16)>;
2428
2429    /// Apache Kafka's `ListConsumerGroupOffsetsHandler` builds `OffsetFetch`
2430    /// with `OffsetFetchRequest.Builder.forTopicNames`, which caps the version
2431    /// at 9. A request for all topics also needs v2. The response names each
2432    /// topic, so the call sends no `Metadata` request.
2433    fn fetch_group_version_cases() -> Vec<ExpectedGroupVersionCase<OffsetFetchRequest, FetchResult>>
2434    {
2435        let fetched = Ok(test_group_offsets());
2436        let case = |name, ranges, requests, result| {
2437            expected_group_version_case(name, GroupRanges::offset_fetch(ranges), requests, result)
2438        };
2439        let legacy_request = |version| {
2440            vec![(
2441                version,
2442                OffsetFetchRequest {
2443                    group_id: "workers".into(),
2444                    topics: None,
2445                    ..Default::default()
2446                },
2447            )]
2448        };
2449        let grouped_request = |version| {
2450            vec![(
2451                version,
2452                OffsetFetchRequest {
2453                    groups: vec![offset_fetch_group("workers")],
2454                    ..Default::default()
2455                },
2456            )]
2457        };
2458        vec![
2459            case(
2460                "coordinator stops at v7",
2461                (2, 7),
2462                legacy_request(7),
2463                fetched.clone(),
2464            ),
2465            case(
2466                "coordinator stops at v8",
2467                (2, 8),
2468                grouped_request(8),
2469                fetched.clone(),
2470            ),
2471            case(
2472                "coordinator stops at v9",
2473                (2, 9),
2474                grouped_request(9),
2475                fetched.clone(),
2476            ),
2477            case(
2478                "coordinator supports v10",
2479                (2, 10),
2480                grouped_request(9),
2481                fetched,
2482            ),
2483            case(
2484                "coordinator supports only v10",
2485                (10, 10),
2486                Vec::new(),
2487                incompatible_group_version(offset_fetch_request::API_KEY, 10, 10),
2488            ),
2489            case(
2490                "coordinator supports only v1",
2491                (1, 1),
2492                Vec::new(),
2493                incompatible_group_version(offset_fetch_request::API_KEY, 1, 1),
2494            ),
2495        ]
2496    }
2497
2498    async fn fetch_group_offsets_by_topic(
2499        name: &'static str,
2500        mut admin: AdminClient,
2501    ) -> FetchResult {
2502        let offsets = admin.list_consumer_group_offsets("workers");
2503        Box::pin(with_incompatible_version(name, offsets)).await
2504    }
2505
2506    #[yare::parameterized(
2507        coordinator_stops_at_v7 = { 0 },
2508        coordinator_stops_at_v8 = { 1 },
2509        coordinator_stops_at_v9 = { 2 },
2510        coordinator_supports_v10 = { 3 },
2511        coordinator_only_supports_v10 = { 4 },
2512        coordinator_only_supports_v1 = { 5 },
2513    )]
2514    fn list_consumer_group_offsets_sends_offset_fetch_by_topic_name(index: usize) {
2515        let case = fetch_group_version_cases().remove(index);
2516        block_on_test(run_group_version_case(
2517            case,
2518            offset_fetch_request::API_KEY,
2519            |_, version| {
2520                encode(
2521                    &offset_fetch_response(version),
2522                    version,
2523                    version >= offset_fetch_request::FLEXIBLE_MIN,
2524                )
2525            },
2526            fetch_group_offsets_by_topic,
2527        ));
2528    }
2529
2530    /// The result of `committed_offsets`, with a broker error as its fields.
2531    type OffsetsResult = Result<GroupOffsets, (&'static str, i16, &'static str, Option<String>)>;
2532
2533    /// Kafka's `OffsetFetchResponse.group` reads the group error code from the
2534    /// top level below v8 and from the group entry from v8.
2535    #[yare::parameterized(
2536        v2_to_v7_shape = { offset_fetch_response(7), Ok(test_group_offsets()) },
2537        v8_and_v9_shape = { offset_fetch_response(9), Ok(test_group_offsets()) },
2538        v2_to_v7_group_error = { OffsetFetchResponse { error_code: 15, ..Default::default() }, Err(("OffsetFetch", 15, "COORDINATOR_NOT_AVAILABLE", Some("group=workers".into()))) },
2539        v8_and_v9_group_error = { OffsetFetchResponse { groups: vec![OffsetFetchResponseGroup { group_id: "workers".into(), error_code: 16, ..Default::default() }], ..Default::default() }, Err(("OffsetFetch", 16, "NOT_COORDINATOR", Some("group=workers".into()))) },
2540    )]
2541    fn committed_offsets_reads_both_response_shapes(
2542        response: OffsetFetchResponse,
2543        expected: OffsetsResult,
2544    ) {
2545        let result: OffsetsResult =
2546            committed_offsets("workers", response).map_err(|error| match error {
2547                AdminError::Broker {
2548                    api,
2549                    code,
2550                    name,
2551                    message,
2552                } => (api, code, name, message),
2553                other => panic!("unexpected error {other:?}"),
2554            });
2555        assert2::assert!(result == expected);
2556    }
2557
2558    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2559    enum RetryExpected {
2560        Success,
2561        OffsetFetch(i16),
2562        CommitOutcome(i16),
2563        FindCoordinator(i16),
2564        Timeout,
2565    }
2566
2567    /// The error codes that the mock brokers answer, one per attempt. The
2568    /// last code repeats. A code of 0 answers with the coordinator or with the
2569    /// offsets of `offset_fetch_response`.
2570    #[derive(Clone, Default)]
2571    struct RetryScript {
2572        find_coordinator: Vec<i16>,
2573        offset_fetch: Vec<i16>,
2574        offset_commit: Vec<i16>,
2575        find_coordinator_requests: usize,
2576        offset_fetch_requests: usize,
2577        offset_commit_requests: usize,
2578        /// The first `unreachable_answers` `FindCoordinator` answers name
2579        /// `unreachable`, an address that refuses connections.
2580        unreachable_answers: usize,
2581        unreachable: Option<SocketAddr>,
2582        /// The coordinator does not answer `OffsetCommit`.
2583        silent_offset_commit: bool,
2584        /// The first `closed_offset_commits` `OffsetCommit` requests close the
2585        /// connection with no answer.
2586        closed_offset_commits: usize,
2587    }
2588
2589    /// An address on which no listener accepts connections.
2590    async fn refused_address() -> SocketAddr {
2591        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind a port");
2592        listener.local_addr().expect("local address")
2593    }
2594
2595    impl RetryScript {
2596        fn next(codes: &[i16], requests: &mut usize) -> i16 {
2597            let code = codes[(*requests).min(codes.len() - 1)];
2598            *requests += 1;
2599            code
2600        }
2601    }
2602
2603    /// A mock broker that answers `FindCoordinator` from `script` with
2604    /// `coordinator` as the coordinator, and `OffsetFetch` and `OffsetCommit`
2605    /// from `script`. An `OffsetCommit` answer puts its code on each
2606    /// partition of the request.
2607    async fn scripted_group_broker(
2608        script: Arc<Mutex<RetryScript>>,
2609        coordinator: Arc<Mutex<Option<SocketAddr>>>,
2610    ) -> MockBroker {
2611        let ranges = GroupRanges {
2612            offset_commit: (2, 9),
2613            offset_fetch: (2, 9),
2614        };
2615        let closing = Arc::clone(&script);
2616        let handler = move |api_key: i16, version: i16, body: &[u8]| match api_key {
2617            api_versions_request::API_KEY => Some(api_versions(ranges)),
2618            offset_commit_request::API_KEY => {
2619                let request: OffsetCommitRequest = decode_request(body, version);
2620                let mut guard = script.lock().expect("script lock");
2621                let script = &mut *guard;
2622                let error_code =
2623                    RetryScript::next(&script.offset_commit, &mut script.offset_commit_requests);
2624                let silent = script.silent_offset_commit;
2625                drop(guard);
2626                if silent {
2627                    return None;
2628                }
2629                let response = offset_commit_response(&request, |_| error_code);
2630                Some(encode(
2631                    &response,
2632                    version,
2633                    version >= offset_commit_request::FLEXIBLE_MIN,
2634                ))
2635            }
2636            find_coordinator_request::API_KEY => {
2637                let mut guard = script.lock().expect("script lock");
2638                let script = &mut *guard;
2639                let error_code = RetryScript::next(
2640                    &script.find_coordinator,
2641                    &mut script.find_coordinator_requests,
2642                );
2643                let addr = match script.unreachable {
2644                    Some(unreachable)
2645                        if script.find_coordinator_requests <= script.unreachable_answers =>
2646                    {
2647                        unreachable
2648                    }
2649                    _ => coordinator
2650                        .lock()
2651                        .expect("coordinator lock")
2652                        .expect("coordinator address"),
2653                };
2654                drop(guard);
2655                Some(encode(
2656                    &mock_coordinator_response(addr, error_code),
2657                    version,
2658                    false,
2659                ))
2660            }
2661            offset_fetch_request::API_KEY => {
2662                let mut guard = script.lock().expect("script lock");
2663                let script = &mut *guard;
2664                let error_code =
2665                    RetryScript::next(&script.offset_fetch, &mut script.offset_fetch_requests);
2666                drop(guard);
2667                let response = if error_code == 0 {
2668                    offset_fetch_response(version)
2669                } else {
2670                    OffsetFetchResponse {
2671                        groups: vec![OffsetFetchResponseGroup {
2672                            group_id: "workers".into(),
2673                            error_code,
2674                            ..Default::default()
2675                        }],
2676                        ..Default::default()
2677                    }
2678                };
2679                Some(encode(&response, version, true))
2680            }
2681            _ => None,
2682        };
2683        MockBroker::start_with_replies(move |api_key, version, _, body| {
2684            if api_key == offset_commit_request::API_KEY {
2685                let mut script = closing.lock().expect("script lock");
2686                let close = script.closed_offset_commits > 0;
2687                if close {
2688                    script.closed_offset_commits -= 1;
2689                    script.offset_commit_requests += 1;
2690                }
2691                drop(script);
2692                if close {
2693                    return MockReply::Close;
2694                }
2695            }
2696            handler(api_key, version, body).map_or(MockReply::Silent, MockReply::Respond)
2697        })
2698        .await
2699        .unwrap()
2700    }
2701
2702    struct ScriptedGroupCluster {
2703        coordinator: MockBroker,
2704        bootstrap: MockBroker,
2705        admin: AdminClient,
2706    }
2707
2708    impl ScriptedGroupCluster {
2709        async fn start(script: Arc<Mutex<RetryScript>>) -> Self {
2710            let coordinator_addr = Arc::new(Mutex::new(None));
2711            let coordinator =
2712                scripted_group_broker(Arc::clone(&script), Arc::clone(&coordinator_addr)).await;
2713            *coordinator_addr.lock().expect("coordinator lock") = Some(coordinator.addr);
2714            let bootstrap =
2715                scripted_group_broker(Arc::clone(&script), Arc::clone(&coordinator_addr)).await;
2716            let admin = connect_admin(bootstrap.addr, "admin connects").await;
2717            Self {
2718                coordinator,
2719                bootstrap,
2720                admin,
2721            }
2722        }
2723    }
2724
2725    async fn run_scripted_call<T, F, Fut>(script: RetryScript, call: F) -> (T, RetryScript)
2726    where
2727        F: FnOnce(AdminClient) -> Fut,
2728        Fut: Future<Output = T>,
2729    {
2730        let shared_script = Arc::new(Mutex::new(script));
2731        let ScriptedGroupCluster {
2732            coordinator,
2733            bootstrap,
2734            admin,
2735        } = ScriptedGroupCluster::start(Arc::clone(&shared_script)).await;
2736        let result = call(admin).await;
2737        bootstrap.stop();
2738        coordinator.stop();
2739        let recorded = shared_script.lock().expect("script lock").clone();
2740        (result, recorded)
2741    }
2742
2743    const GROUP_RETRY_LONG: Duration = Duration::from_secs(5);
2744    const GROUP_RETRY_NOW: Duration = Duration::from_millis(500);
2745
2746    fn group_retry_backoff(timeout: Duration) -> Duration {
2747        if timeout == GROUP_RETRY_LONG {
2748            Duration::from_millis(1)
2749        } else {
2750            timeout
2751        }
2752    }
2753
2754    fn test_retry_policy(timeout: Duration, initial_backoff: Duration) -> RetryPolicy {
2755        RetryPolicy {
2756            timeout,
2757            initial_backoff,
2758            max_backoff: initial_backoff,
2759            jitter: 0.0,
2760            max_retries: u32::MAX,
2761        }
2762    }
2763
2764    fn list_groups_retry_policy(
2765        timeout: Duration,
2766        backoff: Duration,
2767        max_retries: u32,
2768    ) -> RetryPolicy {
2769        RetryPolicy {
2770            timeout,
2771            initial_backoff: backoff,
2772            max_backoff: backoff,
2773            jitter: 0.0,
2774            max_retries,
2775        }
2776    }
2777
2778    #[derive(Clone, Copy)]
2779    struct GroupRetryScenario {
2780        name: &'static str,
2781        find_coordinator: &'static [i16],
2782        offset_fetch: &'static [i16],
2783        offset_commit: &'static [i16],
2784        timeout: Duration,
2785        fetch_expected: RetryExpected,
2786        fetch_calls: (usize, usize),
2787        commit_expected: RetryExpected,
2788        commit_calls: (usize, usize),
2789    }
2790
2791    #[derive(Clone, Copy)]
2792    struct RetryCodes {
2793        find_coordinator: &'static [i16],
2794        offset_fetch: &'static [i16],
2795        offset_commit: &'static [i16],
2796    }
2797
2798    #[derive(Clone, Copy)]
2799    struct RetryExpectations {
2800        fetch: RetryExpected,
2801        commit: RetryExpected,
2802    }
2803
2804    fn successful_retry_case(
2805        name: &'static str,
2806        find_coordinator: &'static [i16],
2807        rpc_errors: &'static [i16],
2808        calls: (usize, usize),
2809    ) -> GroupRetryScenario {
2810        retry_scenario(
2811            name,
2812            RetryCodes {
2813                find_coordinator,
2814                offset_fetch: rpc_errors,
2815                offset_commit: rpc_errors,
2816            },
2817            GROUP_RETRY_LONG,
2818            RetryExpectations {
2819                fetch: RetryExpected::Success,
2820                commit: RetryExpected::CommitOutcome(0),
2821            },
2822            calls,
2823        )
2824    }
2825
2826    fn retry_scenario(
2827        name: &'static str,
2828        codes: RetryCodes,
2829        timeout: Duration,
2830        expected: RetryExpectations,
2831        calls: (usize, usize),
2832    ) -> GroupRetryScenario {
2833        GroupRetryScenario {
2834            name,
2835            find_coordinator: codes.find_coordinator,
2836            offset_fetch: codes.offset_fetch,
2837            offset_commit: codes.offset_commit,
2838            timeout,
2839            fetch_expected: expected.fetch,
2840            fetch_calls: calls,
2841            commit_expected: expected.commit,
2842            commit_calls: calls,
2843        }
2844    }
2845
2846    fn coordinator_recovery_case(name: &'static str, codes: &'static [i16]) -> GroupRetryScenario {
2847        successful_retry_case(name, codes, &[0], (2, 1))
2848    }
2849
2850    fn timed_out_retry_case(name: &'static str, codes: RetryCodes) -> GroupRetryScenario {
2851        retry_scenario(
2852            name,
2853            codes,
2854            GROUP_RETRY_NOW,
2855            RetryExpectations {
2856                fetch: RetryExpected::Timeout,
2857                commit: RetryExpected::Timeout,
2858            },
2859            (1, 1),
2860        )
2861    }
2862
2863    fn group_retry_scenarios() -> Vec<GroupRetryScenario> {
2864        use RetryExpected::{FindCoordinator as FetchFindError, Timeout as FetchTimeout};
2865
2866        vec![
2867            successful_retry_case("no error", &[0], &[0], (1, 1)),
2868            successful_retry_case("coordinator load in progress", &[0], &[14, 14, 0], (1, 3)),
2869            successful_retry_case("coordinator not available", &[0], &[15, 0], (2, 2)),
2870            successful_retry_case("not coordinator", &[0], &[16, 0], (2, 2)),
2871            timed_out_retry_case(
2872                "load in progress timeout",
2873                RetryCodes {
2874                    find_coordinator: &[0],
2875                    offset_fetch: &[14],
2876                    offset_commit: &[27],
2877                },
2878            ),
2879            timed_out_retry_case(
2880                "not coordinator timeout",
2881                RetryCodes {
2882                    find_coordinator: &[0],
2883                    offset_fetch: &[16],
2884                    offset_commit: &[16],
2885                },
2886            ),
2887            retry_scenario(
2888                "group error is final",
2889                RetryCodes {
2890                    find_coordinator: &[0],
2891                    offset_fetch: &[30],
2892                    offset_commit: &[25],
2893                },
2894                GROUP_RETRY_LONG,
2895                RetryExpectations {
2896                    fetch: RetryExpected::OffsetFetch(30),
2897                    commit: RetryExpected::CommitOutcome(25),
2898                },
2899                (1, 1),
2900            ),
2901            retry_scenario(
2902                "another group error is final",
2903                RetryCodes {
2904                    find_coordinator: &[0],
2905                    offset_fetch: &[7],
2906                    offset_commit: &[7],
2907                },
2908                GROUP_RETRY_LONG,
2909                RetryExpectations {
2910                    fetch: RetryExpected::OffsetFetch(7),
2911                    commit: RetryExpected::CommitOutcome(7),
2912                },
2913                (1, 1),
2914            ),
2915            coordinator_recovery_case("find coordinator not available then succeeds", &[15, 0]),
2916            coordinator_recovery_case("find coordinator load in progress then succeeds", &[14, 0]),
2917            retry_scenario(
2918                "find coordinator timeout",
2919                RetryCodes {
2920                    find_coordinator: &[15],
2921                    offset_fetch: &[0],
2922                    offset_commit: &[0],
2923                },
2924                GROUP_RETRY_NOW,
2925                RetryExpectations {
2926                    fetch: FetchTimeout,
2927                    commit: FetchTimeout,
2928                },
2929                (1, 0),
2930            ),
2931            retry_scenario(
2932                "find coordinator authorization error",
2933                RetryCodes {
2934                    find_coordinator: &[30],
2935                    offset_fetch: &[0],
2936                    offset_commit: &[0],
2937                },
2938                GROUP_RETRY_LONG,
2939                RetryExpectations {
2940                    fetch: FetchFindError(30),
2941                    commit: FetchFindError(30),
2942                },
2943                (1, 0),
2944            ),
2945        ]
2946    }
2947
2948    const TIMED_OUT: i16 = 7;
2949
2950    #[derive(Clone, Copy)]
2951    enum RetryApi {
2952        Fetch,
2953        Commit,
2954    }
2955
2956    #[derive(Debug, PartialEq)]
2957    enum RetryObservation {
2958        Fetch(Result<GroupOffsets, RetryExpected>),
2959        Commit(Result<Vec<ConsumerGroupOffsetOutcome>, i16>),
2960    }
2961
2962    async fn alter_consumer_offsets_with_retry(
2963        admin: AdminClient,
2964        timeout: Duration,
2965    ) -> Result<Vec<ConsumerGroupOffsetOutcome>, AdminError> {
2966        let offsets = test_group_offsets();
2967        admin
2968            .alter_group_offsets_with_retry(
2969                GroupOffsetUpdate {
2970                    kind: GroupOffsetKind::Consumer,
2971                    group: "workers",
2972                    offsets: &offsets,
2973                },
2974                test_retry_policy(timeout, group_retry_backoff(timeout)),
2975            )
2976            .await
2977    }
2978
2979    async fn assert_retry_scenario(case: GroupRetryScenario, api: RetryApi) {
2980        let expected = match (api, case.fetch_expected, case.commit_expected) {
2981            (RetryApi::Fetch, RetryExpected::Success, _) => {
2982                RetryObservation::Fetch(Ok(test_group_offsets()))
2983            }
2984            (RetryApi::Fetch, RetryExpected::OffsetFetch(code), _) => {
2985                RetryObservation::Fetch(Err(RetryExpected::OffsetFetch(code)))
2986            }
2987            (RetryApi::Fetch, RetryExpected::FindCoordinator(code), _) => {
2988                RetryObservation::Fetch(Err(RetryExpected::FindCoordinator(code)))
2989            }
2990            (RetryApi::Fetch, RetryExpected::Timeout, _) => {
2991                RetryObservation::Fetch(Err(RetryExpected::Timeout))
2992            }
2993            (RetryApi::Commit, _, RetryExpected::CommitOutcome(code)) => {
2994                RetryObservation::Commit(Ok(vec![ConsumerGroupOffsetOutcome {
2995                    topic: "orders".into(),
2996                    partition: 2,
2997                    error: kafka_error_if(code, None),
2998                }]))
2999            }
3000            (RetryApi::Commit, _, RetryExpected::FindCoordinator(code)) => {
3001                RetryObservation::Commit(Err(code))
3002            }
3003            (RetryApi::Commit, _, RetryExpected::Timeout) => {
3004                RetryObservation::Commit(Err(TIMED_OUT))
3005            }
3006            _ => unreachable!("retry expectation belongs to another API"),
3007        };
3008        let expected_calls = match api {
3009            RetryApi::Fetch => case.fetch_calls,
3010            RetryApi::Commit => case.commit_calls,
3011        };
3012        let mut script = RetryScript {
3013            find_coordinator: case.find_coordinator.to_vec(),
3014            ..RetryScript::default()
3015        };
3016        match api {
3017            RetryApi::Fetch => script.offset_fetch = case.offset_fetch.to_vec(),
3018            RetryApi::Commit => script.offset_commit = case.offset_commit.to_vec(),
3019        }
3020
3021        let ((result, within_deadline), script) = run_scripted_call(script, |admin| async move {
3022            let started = Instant::now();
3023            let result = match api {
3024                RetryApi::Fetch => {
3025                    let result = admin
3026                        .list_consumer_group_offsets_with_retry(
3027                            "workers",
3028                            test_retry_policy(case.timeout, group_retry_backoff(case.timeout)),
3029                        )
3030                        .await
3031                        .map_err(|error| match error {
3032                            AdminError::Broker {
3033                                api: "OffsetFetch",
3034                                code,
3035                                ..
3036                            } => RetryExpected::OffsetFetch(code),
3037                            AdminError::Transport(ClientError::Server { error_code }) => {
3038                                RetryExpected::FindCoordinator(error_code)
3039                            }
3040                            AdminError::Transport(ClientError::Timeout(_)) => {
3041                                RetryExpected::Timeout
3042                            }
3043                            other => panic!("case {}: unexpected error {other:?}", case.name),
3044                        });
3045                    RetryObservation::Fetch(result)
3046                }
3047                RetryApi::Commit => {
3048                    let result = alter_consumer_offsets_with_retry(admin, case.timeout)
3049                        .await
3050                        .map_err(|error| match error {
3051                            AdminError::Transport(ClientError::Server { error_code }) => error_code,
3052                            AdminError::Transport(ClientError::Timeout(_)) => TIMED_OUT,
3053                            other => panic!("case {}: unexpected error {other:?}", case.name),
3054                        });
3055                    RetryObservation::Commit(result)
3056                }
3057            };
3058            let within_deadline = match api {
3059                RetryApi::Fetch => true,
3060                RetryApi::Commit => started.elapsed() < case.timeout + Duration::from_secs(2),
3061            };
3062            (result, within_deadline)
3063        })
3064        .await;
3065        let rpc_calls = match api {
3066            RetryApi::Fetch => script.offset_fetch_requests,
3067            RetryApi::Commit => script.offset_commit_requests,
3068        };
3069        assert!(
3070            (
3071                result,
3072                script.find_coordinator_requests,
3073                rpc_calls,
3074                within_deadline
3075            ) == (expected, expected_calls.0, expected_calls.1, true),
3076            "case {}",
3077            case.name
3078        );
3079    }
3080
3081    /// These named cases exercise the shared retry behavior of `OffsetFetch` and
3082    /// `OffsetCommit`, while preserving each API's result and call-count checks.
3083    #[yare::parameterized(
3084        no_error = { 0 },
3085        coordinator_load_in_progress = { 1 },
3086        coordinator_not_available = { 2 },
3087        not_coordinator = { 3 },
3088        load_timeout = { 4 },
3089        coordinator_timeout = { 5 },
3090        group_error = { 6 },
3091        other_group_error = { 7 },
3092        find_coordinator_not_available = { 8 },
3093        find_coordinator_load = { 9 },
3094        find_coordinator_timeout = { 10 },
3095        find_coordinator_authorization = { 11 },
3096    )]
3097    fn group_offset_retry_scenarios_match_kafka(index: usize) {
3098        block_on_test(async move {
3099            let case = group_retry_scenarios()[index];
3100            assert_retry_scenario(case, RetryApi::Fetch).await;
3101            assert_retry_scenario(case, RetryApi::Commit).await;
3102        });
3103    }
3104
3105    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
3106    enum Coordinator {
3107        UnreachableOnce,
3108        Silent,
3109        ClosedOnce,
3110    }
3111
3112    /// The coordinator retry driver recovers from a connection close, resolves
3113    /// an unreachable coordinator, and times out when the coordinator is silent.
3114    #[yare::parameterized(
3115        closed_connection = { "closed connection", GROUP_RETRY_LONG, Coordinator::ClosedOnce, 1, 2, Ok(0) },
3116        unreachable_coordinator = { "unreachable coordinator", GROUP_RETRY_LONG, Coordinator::UnreachableOnce, 2, 1, Ok(0) },
3117        silent_coordinator = { "silent coordinator", Duration::from_millis(300), Coordinator::Silent, 1, 1, Err(TIMED_OUT) },
3118    )]
3119    #[test_macro(tokio::test(flavor = "multi_thread", worker_threads = 2))]
3120    async fn alter_consumer_group_offsets_recovers_from_connection_failures(
3121        name: &'static str,
3122        timeout: Duration,
3123        coordinator: Coordinator,
3124        find_calls: usize,
3125        commit_calls: usize,
3126        expected: Result<i16, i16>,
3127    ) {
3128        let script = RetryScript {
3129            find_coordinator: vec![0],
3130            offset_commit: vec![0],
3131            unreachable_answers: usize::from(coordinator == Coordinator::UnreachableOnce),
3132            unreachable: Some(refused_address().await),
3133            silent_offset_commit: coordinator == Coordinator::Silent,
3134            closed_offset_commits: usize::from(coordinator == Coordinator::ClosedOnce),
3135            ..RetryScript::default()
3136        };
3137        let ((result, within_deadline), script) = run_scripted_call(script, |admin| async move {
3138            let started = Instant::now();
3139            let result = alter_consumer_offsets_with_retry(admin, timeout)
3140                .await
3141                .map(|_| 0_i16)
3142                .map_err(|error| match error {
3143                    AdminError::Transport(ClientError::Server { error_code }) => error_code,
3144                    AdminError::Transport(ClientError::Timeout(_)) => TIMED_OUT,
3145                    other => panic!("case {name}: unexpected error {other:?}"),
3146                });
3147            (result, started.elapsed() < timeout + Duration::from_secs(2))
3148        })
3149        .await;
3150        assert2::assert!(
3151            (
3152                result,
3153                script.find_coordinator_requests,
3154                script.offset_commit_requests,
3155                within_deadline
3156            ) == (expected, find_calls, commit_calls, true),
3157            "case {name}"
3158        );
3159    }
3160
3161    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3162    async fn group_offset_rpcs_use_the_group_coordinator() {
3163        let ranges = GroupRanges {
3164            offset_commit: (2, 10),
3165            offset_fetch: (2, 10),
3166        };
3167        let coordinator_group_rpcs = Arc::new(AtomicUsize::new(0));
3168        let seen = Arc::clone(&coordinator_group_rpcs);
3169        let coordinator = MockBroker::start(move |api_key, version, _, _| match api_key {
3170            api_versions_request::API_KEY => Some(api_versions(ranges)),
3171            offset_commit_request::API_KEY => {
3172                seen.fetch_add(1, Ordering::SeqCst);
3173                Some(crate::encode_test_struct_field!(
3174                    encode,
3175                    OffsetCommitResponse,
3176                    topics,
3177                    vec![successful_offset_commit_topic()],
3178                    version,
3179                    true,
3180                ))
3181            }
3182            offset_fetch_request::API_KEY => {
3183                counted_reply(&seen, Some(offset_fetch_reply(version)))
3184            }
3185            _ => None,
3186        })
3187        .await
3188        .unwrap();
3189
3190        let bootstrap_group_rpcs = Arc::new(AtomicUsize::new(0));
3191        let bootstrap =
3192            bootstrap_for(&coordinator, ranges, Arc::clone(&bootstrap_group_rpcs)).await;
3193        let mut admin = connect_admin(bootstrap.addr, "admin connects").await;
3194
3195        let committed = admin
3196            .alter_consumer_group_offsets("workers", &test_group_offsets())
3197            .await
3198            .expect("offset commit succeeds");
3199        let mut admin = connect_admin(bootstrap.addr, "second admin connects").await;
3200        let fetched = admin
3201            .list_consumer_group_offsets("workers")
3202            .await
3203            .expect("offset fetch succeeds");
3204
3205        assert!(bootstrap_group_rpcs.load(Ordering::SeqCst) == 0);
3206        assert!(coordinator_group_rpcs.load(Ordering::SeqCst) == 2);
3207        assert!(committed == successful_group_offset_outcome());
3208        assert!(fetched == test_group_offsets());
3209        bootstrap.stop();
3210        coordinator.stop();
3211    }
3212
3213    /// The answer of a mock broker to one `ListGroups` request.
3214    #[derive(Clone)]
3215    enum ListGroupsAnswer {
3216        /// The groups that the broker coordinates. The broker applies the
3217        /// state and type filters of the request.
3218        Groups(Vec<ListedGroup>),
3219        /// A top-level error code.
3220        Error(i16),
3221    }
3222
3223    /// One mock broker of a `ListGroups` case: its highest `ListGroups`
3224    /// version, and its answers, one per request. The last answer repeats.
3225    #[derive(Clone)]
3226    struct ListGroupsBroker {
3227        max_version: i16,
3228        answers: Vec<ListGroupsAnswer>,
3229    }
3230
3231    fn listed(group_id: &str, protocol_type: &str, state: &str, group_type: &str) -> ListedGroup {
3232        ListedGroup {
3233            group_id: group_id.into(),
3234            protocol_type: protocol_type.into(),
3235            group_state: state.into(),
3236            group_type: group_type.into(),
3237            ..Default::default()
3238        }
3239    }
3240
3241    fn consumer_group(group_id: &str) -> ListedGroup {
3242        listed(group_id, "consumer", "Stable", "Classic")
3243    }
3244
3245    fn stable_listing(group_id: &str) -> GroupListing {
3246        GroupListing {
3247            group_id: group_id.into(),
3248            group_type: Some(GroupType::Classic),
3249            protocol_type: "consumer".into(),
3250            group_state: Some(GroupState::Stable),
3251        }
3252    }
3253
3254    fn stable_listings(group_ids: &[&str]) -> Vec<GroupListing> {
3255        group_ids
3256            .iter()
3257            .map(|group_id| stable_listing(group_id))
3258            .collect()
3259    }
3260
3261    fn groups(ids: &[&str]) -> ListGroupsBroker {
3262        ListGroupsBroker {
3263            max_version: 5,
3264            answers: vec![ListGroupsAnswer::Groups(
3265                ids.iter().map(|id| consumer_group(id)).collect(),
3266            )],
3267        }
3268    }
3269
3270    /// A mock broker that answers `Metadata` with every address in `cluster`
3271    /// (node ids from 1) and `ListGroups` from `script`. It records each
3272    /// `ListGroups` request with its version.
3273    async fn list_groups_broker(
3274        script: ListGroupsBroker,
3275        cluster: Arc<Mutex<Vec<SocketAddr>>>,
3276        requests: Arc<Mutex<Vec<(i16, ListGroupsRequest)>>>,
3277    ) -> MockBroker {
3278        MockBroker::start_with_replies(move |api_key, version, _, body| {
3279            MockSaslAnswer::Accept
3280                .reply(api_key, version)
3281                .unwrap()
3282                .unwrap_or_else(|| {
3283                    list_groups_reply(&script, &cluster, &requests, api_key, version, body)
3284                        .map_or(MockReply::Silent, MockReply::Respond)
3285                })
3286        })
3287        .await
3288        .unwrap()
3289    }
3290
3291    /// The reply of a `list_groups_broker` to one request other than SASL.
3292    fn list_groups_reply(
3293        script: &ListGroupsBroker,
3294        cluster: &Mutex<Vec<SocketAddr>>,
3295        requests: &Mutex<Vec<(i16, ListGroupsRequest)>>,
3296        api_key: i16,
3297        version: i16,
3298        body: &[u8],
3299    ) -> Option<Vec<u8>> {
3300        match api_key {
3301            api_versions_request::API_KEY => Some(encode_api_versions(vec![
3302                api_version_entry!(api_versions_request::API_KEY, 0, 0),
3303                api_version_entry!(metadata_request::API_KEY, 12, 12),
3304                api_version_entry!(list_groups_request::API_KEY, 0, script.max_version),
3305            ])),
3306            metadata_request::API_KEY => Some(crate::encode_test_struct_field!(
3307                encode,
3308                MetadataResponse,
3309                brokers,
3310                cluster
3311                    .lock()
3312                    .expect("cluster lock")
3313                    .iter()
3314                    .zip(1..)
3315                    .map(|(addr, node_id)| mock_metadata_broker(*addr, node_id))
3316                    .collect(),
3317                version,
3318                true,
3319            )),
3320            list_groups_request::API_KEY => {
3321                let request: ListGroupsRequest = decode_request(body, version);
3322                let mut requests = requests.lock().expect("requests lock");
3323                let answer = &script.answers[requests.len().min(script.answers.len() - 1)];
3324                let response = match answer {
3325                    ListGroupsAnswer::Groups(groups) => ListGroupsResponse {
3326                        groups: groups
3327                            .iter()
3328                            .filter(|group| {
3329                                (request.states_filter.is_empty()
3330                                    || request.states_filter.contains(&group.group_state))
3331                                    && (request.types_filter.is_empty()
3332                                        || request.types_filter.contains(&group.group_type))
3333                            })
3334                            .cloned()
3335                            .collect(),
3336                        ..Default::default()
3337                    },
3338                    ListGroupsAnswer::Error(error_code) => ListGroupsResponse {
3339                        error_code: *error_code,
3340                        ..Default::default()
3341                    },
3342                };
3343                requests.push((version, request));
3344                drop(requests);
3345                Some(encode(
3346                    &response,
3347                    version,
3348                    version >= list_groups_request::FLEXIBLE_MIN,
3349                ))
3350            }
3351            _ => None,
3352        }
3353    }
3354
3355    /// An expected broker failure: node id, error code and message.
3356    type ExpectedError = (i32, i16, Option<String>);
3357
3358    /// One `ListGroups` fan-out case on a cluster of three mock brokers.
3359    struct ListGroupsCase {
3360        name: &'static str,
3361        brokers: [ListGroupsBroker; 3],
3362        options: ListGroupsOptions,
3363        timeout: Duration,
3364        valid: Vec<GroupListing>,
3365        errors: Vec<ExpectedError>,
3366        /// The `ListGroups` requests that each broker receives.
3367        requests: [Vec<(i16, ListGroupsRequest)>; 3],
3368    }
3369
3370    /// The deadline of a case that ends before it.
3371    const LONG: Duration = Duration::from_secs(10);
3372    /// A deadline that ends before the first retry, which waits `BACKOFF`.
3373    const SHORT: Duration = Duration::from_millis(500);
3374    /// The wait between two `ListGroups` attempts on one broker.
3375    const BACKOFF: Duration = Duration::from_secs(1);
3376
3377    #[derive(Default)]
3378    struct ListGroupsCaseBuilder {
3379        name: &'static str,
3380        brokers: Option<[ListGroupsBroker; 3]>,
3381        options: ListGroupsOptions,
3382        timeout: Option<Duration>,
3383        valid: Vec<GroupListing>,
3384        errors: Vec<ExpectedError>,
3385        requests: Option<[Vec<(i16, ListGroupsRequest)>; 3]>,
3386    }
3387
3388    impl ListGroupsCase {
3389        fn builder() -> ListGroupsCaseBuilder {
3390            ListGroupsCaseBuilder::default()
3391        }
3392    }
3393
3394    impl ListGroupsCaseBuilder {
3395        fn name(mut self, name: &'static str) -> Self {
3396            self.name = name;
3397            self
3398        }
3399
3400        fn brokers(mut self, brokers: [ListGroupsBroker; 3]) -> Self {
3401            self.brokers = Some(brokers);
3402            self
3403        }
3404
3405        fn options(mut self, options: ListGroupsOptions) -> Self {
3406            self.options = options;
3407            self
3408        }
3409
3410        fn timeout(mut self, timeout: Duration) -> Self {
3411            self.timeout = Some(timeout);
3412            self
3413        }
3414
3415        fn valid(mut self, valid: Vec<GroupListing>) -> Self {
3416            self.valid = valid;
3417            self
3418        }
3419
3420        fn errors(mut self, errors: Vec<ExpectedError>) -> Self {
3421            self.errors = errors;
3422            self
3423        }
3424
3425        fn requests(mut self, requests: [Vec<(i16, ListGroupsRequest)>; 3]) -> Self {
3426            self.requests = Some(requests);
3427            self
3428        }
3429
3430        fn build(self) -> ListGroupsCase {
3431            ListGroupsCase {
3432                name: self.name,
3433                brokers: self.brokers.expect("brokers must be set"),
3434                options: self.options,
3435                timeout: self.timeout.unwrap_or(LONG),
3436                valid: self.valid,
3437                errors: self.errors,
3438                requests: self
3439                    .requests
3440                    .unwrap_or_else(|| [vec![plain(5)], vec![plain(5)], vec![plain(5)]]),
3441            }
3442        }
3443    }
3444
3445    fn plain(version: i16) -> (i16, ListGroupsRequest) {
3446        (version, ListGroupsRequest::default())
3447    }
3448
3449    fn stable(version: i16) -> (i16, ListGroupsRequest) {
3450        (
3451            version,
3452            ListGroupsRequest {
3453                states_filter: stable_state_filter(),
3454                ..Default::default()
3455            },
3456        )
3457    }
3458
3459    /// The request for the type filter `Classic` and `Consumer`. The encoder
3460    /// omits `types_filter` below v5.
3461    fn classic_and_consumer(version: i16) -> (i16, ListGroupsRequest) {
3462        (
3463            version,
3464            ListGroupsRequest {
3465                types_filter: if version >= 5 {
3466                    vec!["Consumer".into(), "Classic".into()]
3467                } else {
3468                    Vec::new()
3469                },
3470                ..Default::default()
3471            },
3472        )
3473    }
3474
3475    fn consumer_type(version: i16) -> (i16, ListGroupsRequest) {
3476        (
3477            version,
3478            ListGroupsRequest {
3479                types_filter: vec!["Consumer".into()],
3480                ..Default::default()
3481            },
3482        )
3483    }
3484
3485    /// The message of a `ListGroups` version error on a broker with
3486    /// `ListGroups` v0 to `broker_max`.
3487    fn unsupported(broker_max: i16, client_min: i16) -> String {
3488        AdminError::Transport(ClientError::IncompatibleVersion {
3489            api_key: list_groups_request::API_KEY,
3490            broker_min: 0,
3491            broker_max,
3492            client_min,
3493            client_max: 5,
3494        })
3495        .to_string()
3496    }
3497
3498    fn states(states: &[GroupState]) -> ListGroupsOptions {
3499        ListGroupsOptions::builder()
3500            .group_states(states.iter().copied().collect())
3501            .build()
3502    }
3503
3504    fn stable_state_filter() -> Vec<String> {
3505        vec![GroupState::Stable.to_string()]
3506    }
3507
3508    fn single_broker_error() -> Vec<ExpectedError> {
3509        vec![(2, 31, None)]
3510    }
3511
3512    fn expected_kafka_error(code: i16, message: Option<String>) -> KafkaError {
3513        kafka_error_if(code, message).expect("error code is nonzero")
3514    }
3515
3516    fn types(types: &[GroupType]) -> ListGroupsOptions {
3517        ListGroupsOptions::builder()
3518            .types(types.iter().copied().collect())
3519            .build()
3520    }
3521
3522    fn broker_error(code: i16) -> ListGroupsBroker {
3523        ListGroupsBroker {
3524            max_version: 5,
3525            answers: vec![ListGroupsAnswer::Error(code)],
3526        }
3527    }
3528
3529    /// Kafka's `Call.fail` stops a node call after `retries` retries with a
3530    /// `TimeoutException`, before the deadline.
3531    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3532    async fn list_groups_stops_a_broker_after_the_retry_limit() {
3533        let cluster = Arc::new(Mutex::new(Vec::new()));
3534        let sent = Arc::new(Mutex::new(Vec::new()));
3535        let broker =
3536            list_groups_broker(broker_error(14), Arc::clone(&cluster), Arc::clone(&sent)).await;
3537        *cluster.lock().expect("cluster lock") = vec![broker.addr];
3538        let admin = connect_admin(broker.addr, "admin connects").await;
3539
3540        let options = ListGroupsOptions::builder().build();
3541        let result = run_list_groups_with_retry(
3542            &admin,
3543            &options,
3544            list_groups_retry_policy(Duration::from_secs(10), Duration::from_millis(1), 2),
3545        )
3546        .await;
3547
3548        broker.stop();
3549        let codes = result
3550            .errors
3551            .iter()
3552            .map(|error| error.error.code)
3553            .collect::<Vec<_>>();
3554        assert!((codes, sent.lock().expect("sent lock").len()) == (vec![7], 3));
3555    }
3556
3557    /// Kafka's `listGroups` retries a `Metadata` answer without brokers
3558    /// (`StaleMetadataException`) with the backoff, and fails the call at the
3559    /// deadline.
3560    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3561    async fn list_groups_fails_when_the_metadata_never_names_a_broker() {
3562        let sent = Arc::new(Mutex::new(Vec::new()));
3563        let broker = list_groups_broker(
3564            groups(&[]),
3565            Arc::new(Mutex::new(Vec::new())),
3566            Arc::clone(&sent),
3567        )
3568        .await;
3569        let admin = connect_admin(broker.addr, "admin connects").await;
3570
3571        let result = admin
3572            .list_groups_with_retry(
3573                &ListGroupsOptions::builder().build(),
3574                list_groups_retry_policy(
3575                    Duration::from_millis(50),
3576                    Duration::from_millis(5),
3577                    u32::MAX,
3578                ),
3579            )
3580            .await
3581            .map_err(|error| error.to_string());
3582
3583        broker.stop();
3584        assert!(
3585            (result, sent.lock().expect("sent lock").len())
3586                == (
3587                    Err("protocol: failed to find brokers to send ListGroups: metadata has no brokers"
3588                        .to_owned()),
3589                    0
3590                )
3591        );
3592    }
3593
3594    fn below(max_version: i16, ids: &[&str]) -> ListGroupsBroker {
3595        ListGroupsBroker {
3596            max_version,
3597            ..groups(ids)
3598        }
3599    }
3600
3601    /// Runs `case` on a new cluster of three mock brokers. Broker 1 is the
3602    /// bootstrap broker.
3603    async fn run_list_groups_case(case: ListGroupsCase) {
3604        let cluster = Arc::new(Mutex::new(Vec::new()));
3605        let mut brokers = Vec::new();
3606        let mut requests = Vec::new();
3607        for script in case.brokers {
3608            let sent = Arc::new(Mutex::new(Vec::new()));
3609            brokers.push(list_groups_broker(script, Arc::clone(&cluster), Arc::clone(&sent)).await);
3610            requests.push(sent);
3611        }
3612        *cluster.lock().expect("cluster lock") = brokers.iter().map(|broker| broker.addr).collect();
3613        let admin = connect_admin(brokers[0].addr, "admin connects").await;
3614
3615        let result = run_list_groups_with_retry(
3616            &admin,
3617            &case.options,
3618            list_groups_retry_policy(case.timeout, BACKOFF, u32::MAX),
3619        )
3620        .await;
3621
3622        let expected = ListGroupsResult {
3623            valid: case.valid,
3624            errors: case
3625                .errors
3626                .into_iter()
3627                .map(|(node_id, code, message)| {
3628                    let addr = brokers[usize::try_from(node_id - 1).expect("node id")].addr;
3629                    ListGroupsError {
3630                        node_id,
3631                        host: addr.ip().to_string(),
3632                        port: broker_port(addr),
3633                        error: expected_kafka_error(code, message),
3634                    }
3635                })
3636                .collect(),
3637        };
3638        for broker in brokers {
3639            broker.stop();
3640        }
3641        let requests = requests
3642            .iter()
3643            .map(|sent| sent.lock().expect("requests lock").clone())
3644            .collect::<Vec<_>>();
3645        let name = case.name;
3646        assert!(
3647            (result, requests) == (expected, case.requests.to_vec()),
3648            "case {name}"
3649        );
3650    }
3651
3652    async fn run_list_groups_with_retry(
3653        admin: &AdminClient,
3654        options: &ListGroupsOptions,
3655        policy: RetryPolicy,
3656    ) -> ListGroupsResult {
3657        admin
3658            .list_groups_with_retry(options, policy)
3659            .await
3660            .expect("list_groups succeeds")
3661    }
3662
3663    fn block_on_test(future: impl Future<Output = ()> + Send + 'static) {
3664        runtime::Builder::new_multi_thread()
3665            .worker_threads(2)
3666            .enable_all()
3667            .build()
3668            .expect("tokio runtime")
3669            .block_on(future);
3670    }
3671
3672    fn run_list_groups_case_sync(case: ListGroupsCase) {
3673        block_on_test(run_list_groups_case(case));
3674    }
3675
3676    /// Apache Kafka's `KafkaAdminClient.listGroups` sends `Metadata`, then
3677    /// `ListGroups` to every broker, and merges the answers: one listing per
3678    /// group id, and one error per failed broker. It retries 14 and 15 on the
3679    /// broker until the deadline, and then `Call.fail` gives a
3680    /// `TimeoutException` (7) with the last error as its cause.
3681    #[yare::parameterized(
3682        one_group_each_broker = { 0 },
3683        empty_broker = { 1 },
3684        duplicate_group = { 2 },
3685        broker_timeout = { 3 },
3686        broker_retries = { 4 },
3687        authorization_error = { 5 },
3688    )]
3689    fn list_groups_asks_every_broker_and_merges(index: usize) {
3690        run_list_groups_case_sync(list_groups_merge_cases().remove(index));
3691    }
3692
3693    /// Kafka's `listGroups` sends the state and type filters to every broker,
3694    /// and filters protocol types on the client. `ListGroupsRequest.Builder.build`
3695    /// rejects a state filter below v4 and a type filter below v5, except a
3696    /// type filter of `Classic` and `Consumer` at most, with `Classic`, which
3697    /// it omits. The rejection is an error for that broker only.
3698    #[yare::parameterized(
3699        state_filter = { 0 },
3700        old_state_filter = { 1 },
3701        consumer_type_filter = { 2 },
3702        old_consumer_type_pair = { 3 },
3703        protocol_filter = { 4 },
3704    )]
3705    fn list_groups_sends_filters_as_kafka_does(index: usize) {
3706        run_list_groups_case_sync(list_groups_filter_cases().remove(index));
3707    }
3708
3709    /// Broker 2 of a SASL cluster in
3710    /// `list_groups_isolates_a_secured_broker_that_stalls_or_rejects`.
3711    #[derive(Clone, Copy, Debug)]
3712    enum SecuredBroker {
3713        /// Accepts TCP and never answers the SASL exchange.
3714        Stalls,
3715        /// Answers `SaslAuthenticate` with `SASL_AUTHENTICATION_FAILED` (58).
3716        RejectsAuthentication,
3717    }
3718
3719    /// A started `SecuredBroker`. `connections` counts the TCP connections
3720    /// that it accepts, or the `SaslHandshake` requests that it answers.
3721    struct StartedSecuredBroker {
3722        addr: SocketAddr,
3723        connections: Arc<AtomicUsize>,
3724        mock: Option<MockBroker>,
3725        listener: Option<JoinHandle<()>>,
3726    }
3727
3728    impl SecuredBroker {
3729        async fn start(self) -> StartedSecuredBroker {
3730            let connections = Arc::new(AtomicUsize::new(0));
3731            let counter = Arc::clone(&connections);
3732            match self {
3733                Self::Stalls => {
3734                    let listener = TcpListener::bind("127.0.0.1:0")
3735                        .await
3736                        .expect("listener binds");
3737                    let addr = listener.local_addr().expect("listener address");
3738                    let task = tokio::spawn(async move {
3739                        // Hold every accepted connection open, silent, until the
3740                        // listener stops.
3741                        let mut open = Vec::new();
3742                        while let Ok((stream, _)) = listener.accept().await {
3743                            counter.fetch_add(1, Ordering::SeqCst);
3744                            open.push(stream);
3745                        }
3746                        drop(open);
3747                    });
3748                    StartedSecuredBroker {
3749                        addr,
3750                        connections,
3751                        mock: None,
3752                        listener: Some(task),
3753                    }
3754                }
3755                Self::RejectsAuthentication => {
3756                    let mock = MockBroker::start_with_replies(move |api_key, version, _, _| {
3757                        if api_key == sasl_handshake_request::API_KEY {
3758                            counter.fetch_add(1, Ordering::SeqCst);
3759                        }
3760                        MockSaslAnswer::AuthenticateError(SASL_AUTHENTICATION_FAILED)
3761                            .reply(api_key, version)
3762                            .unwrap()
3763                            .unwrap_or(MockReply::Silent)
3764                    })
3765                    .await
3766                    .unwrap();
3767                    StartedSecuredBroker {
3768                        addr: mock.addr,
3769                        connections,
3770                        mock: Some(mock),
3771                        listener: None,
3772                    }
3773                }
3774            }
3775        }
3776
3777        /// The error code and message that `list_groups` reports for the
3778        /// broker at `addr`.
3779        fn expected_error(self, addr: SocketAddr) -> (i16, Option<String>) {
3780            match self {
3781                Self::Stalls => (
3782                    7,
3783                    Some("ListGroups timed out after 1 attempt(s)".to_owned()),
3784                ),
3785                Self::RejectsAuthentication => (
3786                    SASL_AUTHENTICATION_FAILED,
3787                    Some(
3788                        AdminError::Transport(ClientError::Authentication {
3789                            addr,
3790                            source: AuthenticationError::Sasl(SaslAuthenticationError::Failed(
3791                                "SaslAuthenticate(PLAIN) error_code=58 \
3792                                 error_message=Some(\"rejected by mock broker\")"
3793                                    .to_owned(),
3794                            )),
3795                        })
3796                        .to_string(),
3797                    ),
3798                ),
3799            }
3800        }
3801    }
3802
3803    impl StartedSecuredBroker {
3804        fn stop(self) {
3805            if let Some(mock) = self.mock {
3806                mock.stop();
3807            }
3808            if let Some(listener) = self.listener {
3809                listener.abort();
3810            }
3811        }
3812    }
3813
3814    const SASL_AUTHENTICATION_FAILED: i16 = 58;
3815
3816    /// A broker that stalls during the SASL exchange, or that rejects
3817    /// authentication, fails on its own. The groups of the other brokers stay
3818    /// in the result.
3819    ///
3820    /// Kafka bounds each node call of `listGroups` by the call deadline
3821    /// (`KafkaAdminClient` `timeoutCallsInFlight`, and `Call.fail` gives a
3822    /// `TimeoutException`). Kafka fails a node call with the
3823    /// `AuthenticationException` of the node and does not retry it
3824    /// (`handleResponses`, `Call.fail`: the exception is not a
3825    /// `RetriableException`). `ApiError.fromThrowable` maps
3826    /// `SaslAuthenticationException` to 58.
3827    ///
3828    /// The test runs in real time. With paused time, Tokio moves the clock
3829    /// forward while loopback I/O is in flight, and the healthy brokers time
3830    /// out too.
3831    #[yare::parameterized(
3832        stalls = { SecuredBroker::Stalls },
3833        rejects_authentication = { SecuredBroker::RejectsAuthentication },
3834    )]
3835    #[test_macro(tokio::test(flavor = "multi_thread", worker_threads = 2))]
3836    async fn list_groups_isolates_a_secured_broker_that_stalls_or_rejects(broker_2: SecuredBroker) {
3837        const RETRY: RetryPolicy = RetryPolicy {
3838            timeout: Duration::from_secs(2),
3839            initial_backoff: Duration::from_millis(100),
3840            max_backoff: Duration::from_millis(100),
3841            jitter: 0.0,
3842            max_retries: u32::MAX,
3843        };
3844        let cluster = Arc::new(Mutex::new(Vec::new()));
3845        let requests = Arc::new(Mutex::new(Vec::new()));
3846        let broker_1 =
3847            list_groups_broker(groups(&["a"]), Arc::clone(&cluster), Arc::clone(&requests)).await;
3848        let broker_2_started = broker_2.start().await;
3849        let broker_3 =
3850            list_groups_broker(groups(&["c"]), Arc::clone(&cluster), Arc::clone(&requests)).await;
3851        let broker_2_addr = broker_2_started.addr;
3852        *cluster.lock().expect("cluster lock") = vec![broker_1.addr, broker_2_addr, broker_3.addr];
3853        let admin = AdminClient::connect_secured(
3854            &[broker_1.addr.to_string()],
3855            Some(ClientSecurity {
3856                protocol: krabka_security::ListenerProtocol::SaslPlaintext,
3857                tls: None,
3858                sasl: Some(SaslCredentials::Plain {
3859                    username: "alice".into(),
3860                    password: "secret".into(),
3861                }),
3862                sasl_host: None,
3863            }),
3864        )
3865        .await
3866        .expect("admin connects");
3867
3868        let outcome = timeout(
3869            RETRY.timeout * 5,
3870            admin.list_groups_with_retry(&ListGroupsOptions::builder().build(), RETRY),
3871        )
3872        .await
3873        .map(|result| result.expect("list_groups succeeds"));
3874        let observed = (outcome, broker_2_started.connections.load(Ordering::SeqCst));
3875
3876        let (code, message) = broker_2.expected_error(broker_2_addr);
3877        let expected = (
3878            Ok(ListGroupsResult {
3879                valid: stable_listings(&["a", "c"]),
3880                errors: vec![ListGroupsError {
3881                    node_id: 2,
3882                    host: broker_2_addr.ip().to_string(),
3883                    port: broker_port(broker_2_addr),
3884                    error: expected_kafka_error(code, message),
3885                }],
3886            }),
3887            1,
3888        );
3889        broker_1.stop();
3890        broker_2_started.stop();
3891        broker_3.stop();
3892        assert2::assert!(observed == expected, "case {broker_2:?}");
3893    }
3894
3895    fn list_groups_merge_cases() -> Vec<ListGroupsCase> {
3896        vec![
3897            ListGroupsCase::builder()
3898                .name("one group on each broker")
3899                .brokers([groups(&["a"]), groups(&["b"]), groups(&["c"])])
3900                .valid(stable_listings(&["a", "b", "c"]))
3901                .build(),
3902            ListGroupsCase::builder()
3903                .name("broker 2 has no groups")
3904                .brokers([groups(&["a"]), groups(&[]), groups(&["c"])])
3905                .valid(stable_listings(&["a", "c"]))
3906                .build(),
3907            ListGroupsCase::builder()
3908                .name("a group on two brokers is listed once")
3909                .brokers([groups(&["a"]), groups(&["a", "b"]), groups(&["c"])])
3910                .valid(stable_listings(&["a", "b", "c"]))
3911                .build(),
3912            ListGroupsCase::builder()
3913                .name("broker 2 answers coordinator not available until the timeout")
3914                .brokers([groups(&["a"]), broker_error(15), groups(&["c"])])
3915                .timeout(SHORT)
3916                .valid(stable_listings(&["a", "c"]))
3917                .errors(vec![(
3918                    2,
3919                    7,
3920                    Some(format!(
3921                        "ListGroups timed out after 1 attempt(s); last error: {}",
3922                        AdminError::Broker {
3923                            api: "ListGroups",
3924                            code: 15,
3925                            name: "COORDINATOR_NOT_AVAILABLE",
3926                            message: None,
3927                        }
3928                    )),
3929                )])
3930                .build(),
3931            ListGroupsCase::builder()
3932                .name("broker 2 answers coordinator load in progress, then its groups")
3933                .brokers([
3934                    groups(&["a"]),
3935                    ListGroupsBroker {
3936                        max_version: 5,
3937                        answers: vec![
3938                            ListGroupsAnswer::Error(14),
3939                            ListGroupsAnswer::Error(15),
3940                            ListGroupsAnswer::Groups(vec![consumer_group("b")]),
3941                        ],
3942                    },
3943                    groups(&["c"]),
3944                ])
3945                .valid(vec![
3946                    stable_listing("a"),
3947                    stable_listing("b"),
3948                    stable_listing("c"),
3949                ])
3950                .requests([
3951                    vec![plain(5)],
3952                    vec![plain(5), plain(5), plain(5)],
3953                    vec![plain(5)],
3954                ])
3955                .build(),
3956            ListGroupsCase::builder()
3957                .name("broker 2 answers cluster authorization failed")
3958                .brokers([groups(&["a"]), broker_error(31), groups(&["c"])])
3959                .valid(stable_listings(&["a", "c"]))
3960                .errors(single_broker_error())
3961                .build(),
3962        ]
3963    }
3964
3965    fn list_groups_filter_cases() -> Vec<ListGroupsCase> {
3966        vec![
3967            ListGroupsCase::builder()
3968                .name("state filter Stable is sent to every broker")
3969                .brokers([
3970                    ListGroupsBroker {
3971                        max_version: 5,
3972                        answers: vec![ListGroupsAnswer::Groups(vec![
3973                            listed("a", "consumer", "Empty", "Classic"),
3974                            consumer_group("b"),
3975                        ])],
3976                    },
3977                    groups(&["c"]),
3978                    groups(&[]),
3979                ])
3980                .options(states(&[GroupState::Stable]))
3981                .valid(stable_listings(&["b", "c"]))
3982                .requests([vec![stable(5)], vec![stable(5)], vec![stable(5)]])
3983                .build(),
3984            ListGroupsCase::builder()
3985                .name("state filter on a broker below v4 is unsupported on that broker")
3986                .brokers([groups(&["a"]), below(3, &["b"]), groups(&["c"])])
3987                .options(states(&[GroupState::Stable]))
3988                .valid(stable_listings(&["a", "c"]))
3989                .errors(vec![(2, 35, Some(unsupported(3, 4)))])
3990                .requests([vec![stable(5)], Vec::new(), vec![stable(5)]])
3991                .build(),
3992            ListGroupsCase::builder()
3993                .name("type filter Consumer on a broker below v5 is unsupported on that broker")
3994                .brokers([groups(&["a"]), below(4, &["b"]), groups(&["c"])])
3995                .options(types(&[GroupType::Consumer]))
3996                .errors(vec![(2, 35, Some(unsupported(4, 5)))])
3997                .requests([vec![consumer_type(5)], Vec::new(), vec![consumer_type(5)]])
3998                .build(),
3999            ListGroupsCase::builder()
4000                .name("type filter Classic and Consumer on a broker below v5 is omitted")
4001                .brokers([groups(&["a"]), below(4, &["b"]), groups(&["c"])])
4002                .options(types(&[GroupType::Classic, GroupType::Consumer]))
4003                .valid(vec![
4004                    stable_listing("a"),
4005                    GroupListing {
4006                        group_type: None,
4007                        ..stable_listing("b")
4008                    },
4009                    stable_listing("c"),
4010                ])
4011                .requests([
4012                    vec![classic_and_consumer(5)],
4013                    vec![classic_and_consumer(4)],
4014                    vec![classic_and_consumer(5)],
4015                ])
4016                .build(),
4017            ListGroupsCase::builder()
4018                .name("protocol type filter is applied by the client")
4019                .brokers([
4020                    ListGroupsBroker {
4021                        max_version: 5,
4022                        answers: vec![ListGroupsAnswer::Groups(vec![
4023                            consumer_group("a"),
4024                            listed("connect-a", "connect", "Stable", "Classic"),
4025                            listed("simple", "", "Empty", "Classic"),
4026                        ])],
4027                    },
4028                    groups(&[]),
4029                    ListGroupsBroker {
4030                        max_version: 5,
4031                        answers: vec![ListGroupsAnswer::Groups(vec![listed(
4032                            "share", "share", "Stable", "Share",
4033                        )])],
4034                    },
4035                ])
4036                .options(ListGroupsOptions::for_consumer_groups())
4037                .valid(vec![
4038                    stable_listing("a"),
4039                    GroupListing {
4040                        group_id: "simple".into(),
4041                        group_type: Some(GroupType::Classic),
4042                        protocol_type: String::new(),
4043                        group_state: Some(GroupState::Empty),
4044                    },
4045                ])
4046                .requests([
4047                    vec![classic_and_consumer(5)],
4048                    vec![classic_and_consumer(5)],
4049                    vec![classic_and_consumer(5)],
4050                ])
4051                .build(),
4052        ]
4053    }
4054
4055    /// A listing with no type is not a simple consumer group.
4056    #[yare::parameterized(
4057        classic_without_protocol = {
4058            Some(GroupType::Classic),
4059            "",
4060            true,
4061        },
4062        classic_with_protocol = {
4063            Some(GroupType::Classic),
4064            "consumer",
4065            false,
4066        },
4067        consumer = { Some(GroupType::Consumer), "", false },
4068        unknown = { Some(GroupType::Unknown), "", false },
4069        no_type = { None, "", false },
4070        no_type_with_protocol = { None, "consumer", false },
4071    )]
4072    fn is_simple_consumer_group_matches_kafka(
4073        group_type: Option<GroupType>,
4074        protocol_type: &str,
4075        expected: bool,
4076    ) {
4077        let listing = GroupListing {
4078            group_id: "g".into(),
4079            group_type,
4080            protocol_type: protocol_type.into(),
4081            group_state: None,
4082        };
4083        assert2::assert!(listing.is_simple_consumer_group() == expected);
4084    }
4085
4086    /// Kafka's `GroupListing` has no type or state when the broker sends an
4087    /// empty name, and `GroupState.parse` and `GroupType.parse` ignore case
4088    /// and map an unknown name to `Unknown`.
4089    #[yare::parameterized(
4090        consumer = {
4091            listed("g", "consumer", "Stable", "Consumer"),
4092            GroupListing {
4093                group_id: "g".into(),
4094                group_type: Some(GroupType::Consumer),
4095                protocol_type: "consumer".into(),
4096                group_state: Some(GroupState::Stable),
4097            },
4098        },
4099        empty = {
4100            listed("g", "", "", ""),
4101            GroupListing {
4102                group_id: "g".into(),
4103                group_type: None,
4104                protocol_type: String::new(),
4105                group_state: None,
4106            },
4107        },
4108        streams = {
4109            listed("g", "consumer", "preparingrebalance", "STREAMS"),
4110            GroupListing {
4111                group_id: "g".into(),
4112                group_type: Some(GroupType::Streams),
4113                protocol_type: "consumer".into(),
4114                group_state: Some(GroupState::PreparingRebalance),
4115            },
4116        },
4117        unknown = {
4118            listed("g", "consumer", "Rebalancing", "Future"),
4119            GroupListing {
4120                group_id: "g".into(),
4121                group_type: Some(GroupType::Unknown),
4122                protocol_type: "consumer".into(),
4123                group_state: Some(GroupState::Unknown),
4124            },
4125        },
4126    )]
4127    fn group_listing_reads_listed_group(group: ListedGroup, expected: GroupListing) {
4128        assert2::assert!(GroupListing::from(group) == expected);
4129    }
4130
4131    /// An `ApiVersions` answer that advertises every group RPC this module's
4132    /// new-call tests use, each at its highest defined version, so a test
4133    /// picks that call's `LATEST_STABLE_VERSION` and exercises the real wire
4134    /// shape rather than a downgrade.
4135    fn api_versions_all() -> Vec<u8> {
4136        let api = |api_key, max_version| ApiVersion {
4137            api_key,
4138            min_version: 0,
4139            max_version,
4140            ..Default::default()
4141        };
4142        encode_api_versions(vec![
4143            api(api_versions_request::API_KEY, 0),
4144            api(find_coordinator_request::API_KEY, 0),
4145            api(metadata_request::API_KEY, 13),
4146            api(offset_commit_request::API_KEY, 9),
4147            api(offset_fetch_request::API_KEY, 9),
4148            api(OffsetDeleteRequest::API_KEY, 0),
4149            api(LeaveGroupRequest::API_KEY, 5),
4150            api(AlterShareGroupOffsetsRequest::API_KEY, 1),
4151            api(DeleteShareGroupOffsetsRequest::API_KEY, 0),
4152        ])
4153    }
4154
4155    fn empty_metadata_response(version: i16) -> Vec<u8> {
4156        encode(&MetadataResponse::default(), version, true)
4157    }
4158
4159    fn offset_fetch_response_bytes(version: i16) -> Vec<u8> {
4160        encode(&offset_fetch_response(version), version, true)
4161    }
4162
4163    fn empty_metadata_reply(version: i16) -> Vec<u8> {
4164        empty_metadata_response(version)
4165    }
4166
4167    fn offset_fetch_reply(version: i16) -> Vec<u8> {
4168        offset_fetch_response_bytes(version)
4169    }
4170
4171    fn counted_reply(counter: &AtomicUsize, response: Option<Vec<u8>>) -> Option<Vec<u8>> {
4172        counter.fetch_add(1, Ordering::SeqCst);
4173        response
4174    }
4175
4176    /// Connects an `AdminClient` to a single mock broker that is its own
4177    /// group coordinator: it answers `ApiVersions` with
4178    /// [`api_versions_all`], `FindCoordinator` by naming itself, and
4179    /// `target_api_key` by calling `respond` with the next code out of
4180    /// `codes` (the last code repeats, as [`RetryScript::next`] gives it) and
4181    /// the negotiated version. `Metadata` answers empty.
4182    ///
4183    /// Returns the connected client, the broker (the caller stops it), and
4184    /// the number of `target_api_key` requests the broker answered.
4185    async fn connect_self_coordinating(
4186        target_api_key: i16,
4187        codes: Vec<i16>,
4188        respond: impl Fn(i16, i16) -> Vec<u8> + Send + Sync + 'static,
4189    ) -> (AdminClient, MockBroker, Arc<AtomicUsize>) {
4190        let coordinator_addr = Arc::new(Mutex::new(None::<SocketAddr>));
4191        let addr_in_mock = Arc::clone(&coordinator_addr);
4192        let script = Arc::new(Mutex::new((codes, 0_usize)));
4193        let calls = Arc::new(AtomicUsize::new(0));
4194        let calls_in_mock = Arc::clone(&calls);
4195        let broker = MockBroker::start(move |api_key, version, _, _| {
4196            if api_key == target_api_key {
4197                calls_in_mock.fetch_add(1, Ordering::SeqCst);
4198                let mut guard = script.lock().expect("script lock");
4199                let (codes, requests) = &mut *guard;
4200                let code = RetryScript::next(codes, requests);
4201                drop(guard);
4202                Some(respond(code, version))
4203            } else {
4204                match api_key {
4205                    api_versions_request::API_KEY => Some(api_versions_all()),
4206                    find_coordinator_request::API_KEY => {
4207                        let addr = addr_in_mock
4208                            .lock()
4209                            .expect("coordinator lock")
4210                            .expect("coordinator address set before use");
4211                        Some(encode(&mock_coordinator_response(addr, 0), version, false))
4212                    }
4213                    metadata_request::API_KEY => Some(empty_metadata_reply(version)),
4214                    _ => None,
4215                }
4216            }
4217        })
4218        .await
4219        .unwrap();
4220        *coordinator_addr.lock().expect("coordinator lock") = Some(broker.addr);
4221        let admin = connect_admin(broker.addr, "admin connects").await;
4222        (admin, broker, calls)
4223    }
4224
4225    fn successful_group_offset_outcome() -> Vec<ConsumerGroupOffsetOutcome> {
4226        vec![ConsumerGroupOffsetOutcome {
4227            topic: "orders".into(),
4228            partition: 2,
4229            error: None,
4230        }]
4231    }
4232
4233    macro_rules! successful_offset_topic {
4234        ($topic:ident, $partition:ident, $name_field:ident) => {
4235            $topic {
4236                $name_field: "orders".into(),
4237                partitions: vec![$partition {
4238                    partition_index: 2,
4239                    error_code: 0,
4240                    ..Default::default()
4241                }],
4242                ..Default::default()
4243            }
4244        };
4245    }
4246
4247    fn successful_offset_delete_topic() -> OffsetDeleteResponseTopic {
4248        successful_offset_topic!(
4249            OffsetDeleteResponseTopic,
4250            OffsetDeleteResponsePartition,
4251            name
4252        )
4253    }
4254
4255    fn offset_delete_response(error_code: i16, version: i16) -> Vec<u8> {
4256        let topics =
4257            successful_response_rows(error_code, || vec![successful_offset_delete_topic()]);
4258        encode(
4259            &OffsetDeleteResponse {
4260                error_code,
4261                topics,
4262                ..Default::default()
4263            },
4264            version,
4265            false,
4266        )
4267    }
4268
4269    fn offset_commit_success_response(version: i16) -> Vec<u8> {
4270        encode(
4271            &OffsetCommitResponse {
4272                topics: vec![successful_offset_commit_topic()],
4273                ..Default::default()
4274            },
4275            version,
4276            true,
4277        )
4278    }
4279
4280    fn delete_share_offsets_response(error_code: i16, version: i16) -> Vec<u8> {
4281        let responses = successful_response_rows(error_code, || {
4282            vec![DeleteShareGroupOffsetsResponseTopic {
4283                topic_name: "orders".into(),
4284                error_code: 0,
4285                ..Default::default()
4286            }]
4287        });
4288        crate::encode_test_struct_fields!(
4289            encode,
4290            DeleteShareGroupOffsetsResponse,
4291            { error_code: error_code, responses: responses },
4292            version,
4293            true,
4294        )
4295    }
4296
4297    fn successful_offset_commit_topic() -> OffsetCommitResponseTopic {
4298        successful_offset_topic!(
4299            OffsetCommitResponseTopic,
4300            OffsetCommitResponsePartition,
4301            name
4302        )
4303    }
4304
4305    fn successful_share_offset_topic() -> AlterShareGroupOffsetsResponseTopic {
4306        successful_offset_topic!(
4307            AlterShareGroupOffsetsResponseTopic,
4308            AlterShareGroupOffsetsResponsePartition,
4309            topic_name
4310        )
4311    }
4312
4313    fn successful_response_rows<T>(error_code: i16, rows: impl FnOnce() -> Vec<T>) -> Vec<T> {
4314        if error_code == 0 { rows() } else { Vec::new() }
4315    }
4316
4317    fn assert_single_group_offset_success(
4318        result: Result<Vec<ConsumerGroupOffsetOutcome>, String>,
4319        calls: usize,
4320    ) {
4321        assert!((result, calls) == (Ok(successful_group_offset_outcome()), 1));
4322    }
4323
4324    struct CoordinatorRetryCase<T> {
4325        name: &'static str,
4326        codes: Vec<i16>,
4327        expected_calls: usize,
4328        expected: Result<T, i16>,
4329    }
4330
4331    #[bon::bon]
4332    impl<T> CoordinatorRetryCase<T> {
4333        #[builder]
4334        fn success(name: &'static str, codes: Vec<i16>, expected_calls: usize, outcome: T) -> Self {
4335            Self {
4336                name,
4337                codes,
4338                expected_calls,
4339                expected: Ok(outcome),
4340            }
4341        }
4342
4343        fn error(name: &'static str, code: i16) -> Self {
4344            Self {
4345                name,
4346                codes: vec![code],
4347                expected_calls: 1,
4348                expected: Err(code),
4349            }
4350        }
4351    }
4352
4353    impl<T: Clone> CoordinatorRetryCase<T> {
4354        fn standard(outcome: &T) -> Vec<Self> {
4355            vec![
4356                Self::success()
4357                    .name("no error")
4358                    .codes(vec![0])
4359                    .expected_calls(1)
4360                    .outcome(outcome.clone())
4361                    .call(),
4362                Self::success()
4363                    .name("coordinator load in progress retries on the same coordinator")
4364                    .codes(vec![14, 0])
4365                    .expected_calls(2)
4366                    .outcome(outcome.clone())
4367                    .call(),
4368                Self::success()
4369                    .name("not coordinator finds the coordinator again")
4370                    .codes(vec![16, 0])
4371                    .expected_calls(2)
4372                    .outcome(outcome.clone())
4373                    .call(),
4374            ]
4375        }
4376
4377        fn with_rebalance_in_progress(outcome: &T) -> Vec<Self> {
4378            let mut cases = Self::standard(outcome);
4379            cases.push(
4380                Self::success()
4381                    .name("rebalance in progress retries on the same coordinator")
4382                    .codes(vec![27, 0])
4383                    .expected_calls(2)
4384                    .outcome(outcome.clone())
4385                    .call(),
4386            );
4387            cases
4388        }
4389
4390        fn with_authorization_failure(mut cases: Vec<Self>) -> Vec<Self> {
4391            cases.push(Self::error("group authorization failed is final", 30));
4392            cases
4393        }
4394    }
4395
4396    #[bon::builder]
4397    async fn run_coordinator_retry_cases<T, F, Fut, Resp>(
4398        api_key: i16,
4399        respond: Resp,
4400        cases: Vec<CoordinatorRetryCase<T>>,
4401        action: F,
4402    ) where
4403        T: PartialEq + Debug,
4404        Resp: Fn(i16, i16) -> Vec<u8> + Copy + Send + Sync + 'static,
4405        F: Fn(AdminClient) -> Fut,
4406        Fut: Future<Output = Result<T, AdminError>>,
4407    {
4408        for case in cases {
4409            let (admin, broker, calls) =
4410                connect_self_coordinating(api_key, case.codes, respond).await;
4411            let result = action(admin).await.map_err(|error| match error {
4412                AdminError::Broker { code, .. } => code,
4413                other => panic!("case {}: unexpected error {other:?}", case.name),
4414            });
4415            broker.stop();
4416            assert!(
4417                (result, calls.load(Ordering::SeqCst)) == (case.expected, case.expected_calls),
4418                "case {}",
4419                case.name
4420            );
4421        }
4422    }
4423
4424    macro_rules! run_retry_cases {
4425        ($api_key:expr, $respond:expr, $cases:expr, $action:expr) => {
4426            run_coordinator_retry_cases()
4427                .api_key($api_key)
4428                .respond($respond)
4429                .cases($cases)
4430                .action($action)
4431                .call()
4432                .await
4433        };
4434    }
4435
4436    async fn delete_consumer_offsets(
4437        mut admin: AdminClient,
4438    ) -> Result<Vec<ConsumerGroupOffsetOutcome>, AdminError> {
4439        admin
4440            .delete_consumer_group_offsets("workers", &[("orders".into(), 2)])
4441            .await
4442    }
4443
4444    async fn alter_share_offsets(
4445        mut admin: AdminClient,
4446    ) -> Result<Vec<ConsumerGroupOffsetOutcome>, AdminError> {
4447        admin
4448            .alter_share_group_offsets("workers", &test_group_offsets())
4449            .await
4450    }
4451
4452    async fn delete_share_offsets(
4453        mut admin: AdminClient,
4454    ) -> Result<Vec<(String, Option<KafkaError>)>, AdminError> {
4455        admin
4456            .delete_share_group_offsets("workers", &["orders".to_owned()])
4457            .await
4458    }
4459
4460    /// Apache Kafka's `DeleteConsumerGroupOffsetsHandler.handleResponse`
4461    /// reads the top-level `error_code` as a group-level failure, retried the
4462    /// same way as `DeleteGroups`, and otherwise returns one outcome per
4463    /// requested partition.
4464    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4465    async fn delete_consumer_group_offsets_retries_coordinator_errors_as_kafka_does() {
4466        let respond = offset_delete_response;
4467        let outcome = successful_group_offset_outcome();
4468        let mut cases = CoordinatorRetryCase::standard(&outcome);
4469        cases.push(CoordinatorRetryCase::error("non-empty group is final", 68));
4470        run_retry_cases!(
4471            OffsetDeleteRequest::API_KEY,
4472            respond,
4473            cases,
4474            delete_consumer_offsets
4475        );
4476    }
4477
4478    #[derive(Clone, Copy)]
4479    enum StreamsGroupOffsetCall {
4480        Delete,
4481        Alter,
4482    }
4483
4484    impl StreamsGroupOffsetCall {
4485        fn api_key(self) -> i16 {
4486            match self {
4487                Self::Delete => OffsetDeleteRequest::API_KEY,
4488                Self::Alter => offset_commit_request::API_KEY,
4489            }
4490        }
4491
4492        fn response(self, version: i16) -> Vec<u8> {
4493            match self {
4494                Self::Delete => offset_delete_response(0, version),
4495                Self::Alter => offset_commit_success_response(version),
4496            }
4497        }
4498
4499        async fn call(
4500            self,
4501            admin: &mut AdminClient,
4502        ) -> Result<Vec<ConsumerGroupOffsetOutcome>, AdminError> {
4503            match self {
4504                Self::Delete => {
4505                    admin
4506                        .delete_streams_group_offsets("workers", &[("orders".into(), 2)])
4507                        .await
4508                }
4509                Self::Alter => {
4510                    admin
4511                        .alter_streams_group_offsets("workers", &test_group_offsets())
4512                        .await
4513                }
4514            }
4515        }
4516    }
4517
4518    /// Kafka's Streams group offset APIs delegate to their Consumer group
4519    /// counterparts.
4520    #[yare::parameterized(
4521        delete = { StreamsGroupOffsetCall::Delete },
4522        alter = { StreamsGroupOffsetCall::Alter },
4523    )]
4524    #[test_macro(tokio::test(flavor = "multi_thread", worker_threads = 2))]
4525    async fn streams_group_offset_calls_delegate_to_consumer_calls(call: StreamsGroupOffsetCall) {
4526        let respond = move |_code: i16, version: i16| call.response(version);
4527        let (mut admin, broker, calls) =
4528            connect_self_coordinating(call.api_key(), vec![0], respond).await;
4529        let result = call
4530            .call(&mut admin)
4531            .await
4532            .map_err(|error| error.to_string());
4533        broker.stop();
4534        assert_single_group_offset_success(result, calls.load(Ordering::SeqCst));
4535    }
4536
4537    /// Apache Kafka's `RemoveMembersFromConsumerGroupHandler.handleResponse`
4538    /// reads the top-level `error_code` as a group-level failure, retried the
4539    /// same way as `DeleteGroups`, and otherwise maps each answered member to
4540    /// its error. `removeMembersFromConsumerGroup` names each member by its
4541    /// instance id with `UNKNOWN_MEMBER_ID` and sends the default reason.
4542    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4543    async fn remove_members_from_consumer_group_retries_coordinator_errors_as_kafka_does() {
4544        let answered = |instance_id: &str, error_code| MemberResponse {
4545            member_id: String::new(),
4546            group_instance_id: Some(instance_id.into()),
4547            error_code,
4548            ..Default::default()
4549        };
4550        let respond = move |code: i16, version: i16| {
4551            let members = successful_response_rows(code, || {
4552                // The coordinator answers in its own order.
4553                vec![answered("b", 25), answered("a", 0)]
4554            });
4555            let response = LeaveGroupResponse {
4556                error_code: code,
4557                members,
4558                ..Default::default()
4559            };
4560            encode_flexible(&response, version)
4561        };
4562        let removed = |instance_id: &str| RemovedMember {
4563            member_id: String::new(),
4564            group_instance_id: Some(instance_id.into()),
4565        };
4566        let outcome = RemovedMembers {
4567            members: maplit::btreemap! {
4568                removed("a") => None,
4569                removed("b") => Some(KafkaError {
4570                code: 25,
4571                name: "UNKNOWN_MEMBER_ID",
4572                message: None,
4573            }),
4574            },
4575        };
4576        let cases = CoordinatorRetryCase::with_authorization_failure(
4577            CoordinatorRetryCase::standard(&outcome),
4578        );
4579        run_retry_cases!(
4580            LeaveGroupRequest::API_KEY,
4581            respond,
4582            cases,
4583            |mut admin| async move {
4584                admin
4585                    .remove_members_from_consumer_group(
4586                        "workers",
4587                        &[
4588                            MemberToRemove {
4589                                group_instance_id: "a".into(),
4590                            },
4591                            MemberToRemove {
4592                                group_instance_id: "b".into(),
4593                            },
4594                        ],
4595                        &RemoveMembersOptions::builder().build(),
4596                    )
4597                    .await
4598            }
4599        );
4600    }
4601
4602    /// Kafka's `removeMembersFromConsumerGroup` sends each member with
4603    /// `UNKNOWN_MEMBER_ID`, its instance id and the reason, which is the
4604    /// default for `None` or empty and is cut to 255 characters.
4605    #[yare::parameterized(
4606        none = { None, DEFAULT_LEAVE_GROUP_REASON.to_owned() },
4607        empty = { Some(String::new()), DEFAULT_LEAVE_GROUP_REASON.to_owned() },
4608        short = { Some("rolling restart".to_owned()), "rolling restart".to_owned() },
4609        long = { Some("r".repeat(300)), "r".repeat(255) },
4610    )]
4611    fn leave_group_reason_matches_kafka(reason: Option<String>, expected: String) {
4612        assert2::assert!(leave_group_reason(reason.as_deref()) == expected);
4613    }
4614
4615    /// `RemoveMembersFromConsumerGroupResult.memberResult` looks a member up
4616    /// by `UNKNOWN_MEMBER_ID` and its instance id, and gives an error for a
4617    /// member that the answer does not name.
4618    #[test]
4619    fn removed_members_member_result_matches_kafka() {
4620        let member = |instance_id: &str| MemberToRemove {
4621            group_instance_id: instance_id.into(),
4622        };
4623        let fenced = KafkaError {
4624            code: 82,
4625            name: kafka_error_name(82),
4626            message: None,
4627        };
4628        let result = RemovedMembers {
4629            members: maplit::btreemap! {
4630                RemovedMember {
4631                member_id: String::new(),
4632                group_instance_id: Some("a".into()),
4633            } => None,
4634                RemovedMember {
4635                member_id: String::new(),
4636                group_instance_id: Some("b".into()),
4637            } => Some(fenced.clone()),
4638            },
4639        };
4640        assert!(
4641            [member("a"), member("b"), member("c")].map(|member| result.member_result(&member))
4642                == [
4643                    Ok(()),
4644                    Err(fenced),
4645                    Err(KafkaError {
4646                        code: -1,
4647                        name: "UNKNOWN_SERVER_ERROR",
4648                        message: Some(
4649                            "Member \"c\" was not included in the removal response".into()
4650                        ),
4651                    }),
4652                ]
4653        );
4654    }
4655
4656    /// Apache Kafka's `AlterShareGroupOffsetsHandler.handleResponse` retries
4657    /// a top-level 14 or 27 on the same coordinator, finds the coordinator
4658    /// again on 15 or 16, and puts every other top-level code on each
4659    /// requested partition.
4660    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4661    async fn alter_share_group_offsets_retries_coordinator_errors_as_kafka_does() {
4662        let respond = |code: i16, version: i16| {
4663            let responses =
4664                successful_response_rows(code, || vec![successful_share_offset_topic()]);
4665            let response = AlterShareGroupOffsetsResponse {
4666                error_code: code,
4667                responses,
4668                ..Default::default()
4669            };
4670            encode_flexible(&response, version)
4671        };
4672        let outcome = successful_group_offset_outcome();
4673        let mut cases = CoordinatorRetryCase::with_rebalance_in_progress(&outcome);
4674        cases.push(
4675            CoordinatorRetryCase::success()
4676                .name("a final group error is the error of each partition")
4677                .codes(vec![68])
4678                .expected_calls(1)
4679                .outcome(vec![ConsumerGroupOffsetOutcome {
4680                    topic: "orders".into(),
4681                    partition: 2,
4682                    error: Some(KafkaError {
4683                        code: 68,
4684                        name: "NON_EMPTY_GROUP",
4685                        message: None,
4686                    }),
4687                }])
4688                .call(),
4689        );
4690        run_retry_cases!(
4691            AlterShareGroupOffsetsRequest::API_KEY,
4692            respond,
4693            cases,
4694            alter_share_offsets
4695        );
4696    }
4697
4698    /// Apache Kafka's `KafkaAdminClient.listStreamsGroupOffsets` delegates to
4699    /// `listConsumerGroupOffsets` verbatim.
4700    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4701    async fn list_streams_group_offsets_delegates_to_offset_fetch() {
4702        let respond =
4703            |_code: i16, version: i16| encode(&offset_fetch_response(version), version, true);
4704        let (mut admin, broker, calls) =
4705            connect_self_coordinating(offset_fetch_request::API_KEY, vec![0], respond).await;
4706        let result = admin
4707            .list_streams_group_offsets("workers")
4708            .await
4709            .map_err(|error| error.to_string());
4710        broker.stop();
4711        assert!((result, calls.load(Ordering::SeqCst)) == (Ok(test_group_offsets()), 1));
4712    }
4713
4714    /// Apache Kafka's `DeleteShareGroupOffsetsHandler.handleResponse` reads
4715    /// the top-level `error_code` as a group-level failure, retried the same
4716    /// way as `DeleteGroups`, and otherwise returns one `(topic, error)` pair
4717    /// per requested topic.
4718    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4719    async fn delete_share_group_offsets_retries_coordinator_errors_as_kafka_does() {
4720        let respond = delete_share_offsets_response;
4721        let outcome = vec![("orders".to_owned(), None)];
4722        let cases = CoordinatorRetryCase::with_authorization_failure(
4723            CoordinatorRetryCase::with_rebalance_in_progress(&outcome),
4724        );
4725        run_retry_cases!(
4726            DeleteShareGroupOffsetsRequest::API_KEY,
4727            respond,
4728            cases,
4729            delete_share_offsets
4730        );
4731    }
4732}