Skip to main content

krabka_client_admin/
topic_descriptions.rs

1//! Topic listing and description, as Kafka's `Admin.listTopics` and
2//! `Admin.describeTopics`.
3//!
4//! `listTopics` and `describeTopics` by topic ID read `Metadata`.
5//! `describeTopics` by name first reads the cluster's nodes with
6//! `DescribeCluster`, then pages through `DescribeTopicPartitions` (KIP-966),
7//! and falls back to `Metadata` when the peer does not support it, as Kafka's
8//! `KafkaAdminClient` does.
9
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    mem,
13};
14
15use krabka_client_core::ClientError;
16use krabka_protocol::{
17    ProtocolRequest as _,
18    owned::{
19        describe_topic_partitions_request::{
20            Cursor as RequestCursor, DescribeTopicPartitionsRequest, TopicRequest,
21        },
22        describe_topic_partitions_response::{
23            DescribeTopicPartitionsResponse, DescribeTopicPartitionsResponsePartition,
24            DescribeTopicPartitionsResponseTopic,
25        },
26        metadata_request::{MetadataRequest, MetadataRequestTopic},
27        metadata_response::{MetadataResponse, MetadataResponsePartition, MetadataResponseTopic},
28    },
29    primitives::uuid::Uuid as ProtoUuid,
30};
31use uuid::Uuid;
32
33use crate::{
34    AdminClient, AdminError, ClusterNode, DescribeClusterOptions, KafkaError,
35    cluster::authorized_operations, groups::list_groups_kafka_error, kafka_error_name,
36    retry::ControllerRetry, users::AclOperation,
37};
38
39/// `UNKNOWN_TOPIC_OR_PARTITION`.
40const UNKNOWN_TOPIC_OR_PARTITION: i16 = 3;
41/// `INVALID_TOPIC_EXCEPTION`.
42const INVALID_TOPIC_EXCEPTION: i16 = 17;
43/// `UNKNOWN_TOPIC_ID`.
44const UNKNOWN_TOPIC_ID: i16 = 100;
45
46/// What [`AdminClient::list_topics`] lists, as Kafka's `ListTopicsOptions`.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, bon::Builder)]
48pub struct ListTopicsOptions {
49    /// List internal topics, such as `__consumer_offsets`, too.
50    #[builder(default)]
51    pub list_internal: bool,
52}
53
54/// One topic of [`AdminClient::list_topics`], as Kafka's `TopicListing`.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct TopicListing {
57    /// The topic name.
58    pub name: String,
59    /// The topic ID, or `None` when the response carries the zero ID.
60    pub topic_id: Option<Uuid>,
61    /// Whether the topic is internal.
62    pub is_internal: bool,
63}
64
65/// What [`AdminClient::describe_topics`] asks for, as Kafka's
66/// `DescribeTopicsOptions`.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, bon::Builder)]
68pub struct DescribeTopicsOptions {
69    /// Ask for the operations that the caller may perform on each topic.
70    #[builder(default)]
71    pub include_authorized_operations: bool,
72    /// The most partitions that one `DescribeTopicPartitions` answer
73    /// carries. Kafka's default is 2000.
74    #[builder(default = 2000)]
75    pub partition_size_limit_per_response: i32,
76}
77
78impl Default for DescribeTopicsOptions {
79    fn default() -> Self {
80        Self {
81            include_authorized_operations: false,
82            partition_size_limit_per_response: 2000,
83        }
84    }
85}
86
87/// One partition of a [`TopicDescription`], as Kafka's
88/// `TopicPartitionInfo`.
89///
90/// A replica that the cluster's node list does not name appears with an
91/// empty host and port -1, as Kafka's `new Node(id, "", -1)`.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct TopicPartitionInfo {
94    /// The partition index.
95    pub partition: i32,
96    /// The leader, or `None` when the partition has no leader or the leader
97    /// is not a known node.
98    pub leader: Option<ClusterNode>,
99    /// The replicas, preferred replica first.
100    pub replicas: Vec<ClusterNode>,
101    /// The in-sync replicas.
102    pub isr: Vec<ClusterNode>,
103    /// The eligible leader replicas (KIP-966). Always empty through
104    /// `Metadata`.
105    pub elr: Vec<ClusterNode>,
106    /// The last known eligible leader replicas (KIP-966). Always empty
107    /// through `Metadata`.
108    pub last_known_elr: Vec<ClusterNode>,
109}
110
111/// One topic of [`AdminClient::describe_topics`], as Kafka's
112/// `TopicDescription`.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct TopicDescription {
115    /// The topic name.
116    pub name: String,
117    /// The topic ID, or `None` when the response carries the zero ID.
118    pub topic_id: Option<Uuid>,
119    /// Whether the topic is internal.
120    pub is_internal: bool,
121    /// The partitions, in partition order.
122    pub partitions: Vec<TopicPartitionInfo>,
123    /// The operations that the caller may perform on the topic, or `None`
124    /// when the caller did not ask for them.
125    pub authorized_operations: Option<BTreeSet<AclOperation>>,
126}
127
128/// The description of each topic of a describe call, or its error.
129pub type TopicDescriptions<K> = BTreeMap<K, Result<TopicDescription, KafkaError>>;
130
131fn topic_id(id: ProtoUuid) -> Option<Uuid> {
132    (id != ProtoUuid::ZERO).then(|| Uuid::from_bytes(id.0))
133}
134
135fn topic_error(code: i16, message: String) -> KafkaError {
136    KafkaError {
137        code,
138        name: kafka_error_name(code),
139        message: Some(message),
140    }
141}
142
143/// The node of `id`, or Kafka's placeholder `Node(id, "", -1)`.
144fn node(nodes: &BTreeMap<i32, ClusterNode>, id: i32) -> ClusterNode {
145    nodes.get(&id).cloned().unwrap_or_else(|| ClusterNode {
146        id,
147        host: String::new(),
148        port: -1,
149        rack: None,
150        is_fenced: false,
151    })
152}
153
154fn node_list(nodes: &BTreeMap<i32, ClusterNode>, ids: &[i32]) -> Vec<ClusterNode> {
155    ids.iter().map(|id| node(nodes, *id)).collect()
156}
157
158/// The request of Kafka's `MetadataRequest.Builder.allTopics()`.
159fn all_topics_request() -> MetadataRequest {
160    MetadataRequest {
161        topics: None,
162        allow_auto_topic_creation: true,
163        ..Default::default()
164    }
165}
166
167/// The listings of a `Metadata` answer, as Kafka's `listTopics`
168/// `handleResponse` reads it.
169fn topic_listings(
170    response: MetadataResponse,
171    options: ListTopicsOptions,
172) -> BTreeMap<String, TopicListing> {
173    response
174        .topics
175        .into_iter()
176        .filter(|topic| !topic.is_internal || options.list_internal)
177        .filter_map(|topic| {
178            let name = topic.name?;
179            Some((
180                name.clone(),
181                TopicListing {
182                    name,
183                    topic_id: topic_id(topic.topic_id),
184                    is_internal: topic.is_internal,
185                },
186            ))
187        })
188        .collect()
189}
190
191/// One `DescribeTopicPartitions` request, as Kafka's
192/// `generateDescribeTopicsCallWithDescribeTopicPartitionsApi`
193/// `createRequest` builds it. The cursor points past the partitions already
194/// read of the topic that the previous answer cut.
195fn describe_partitions_request(
196    pending: &BTreeSet<String>,
197    partial: Option<&TopicDescription>,
198    options: DescribeTopicsOptions,
199) -> DescribeTopicPartitionsRequest {
200    DescribeTopicPartitionsRequest {
201        topics: pending
202            .iter()
203            .map(|name| TopicRequest {
204                name: name.clone(),
205                ..Default::default()
206            })
207            .collect(),
208        response_partition_limit: options.partition_size_limit_per_response,
209        cursor: partial.map(|partial| RequestCursor {
210            topic_name: partial.name.clone(),
211            partition_index: i32::try_from(partial.partitions.len()).unwrap_or(i32::MAX),
212            ..Default::default()
213        }),
214        ..Default::default()
215    }
216}
217
218fn partition_from_describe(
219    partition: &DescribeTopicPartitionsResponsePartition,
220    nodes: &BTreeMap<i32, ClusterNode>,
221) -> TopicPartitionInfo {
222    TopicPartitionInfo {
223        partition: partition.partition_index,
224        leader: nodes.get(&partition.leader_id).cloned(),
225        replicas: node_list(nodes, &partition.replica_nodes),
226        isr: node_list(nodes, &partition.isr_nodes),
227        elr: node_list(
228            nodes,
229            partition
230                .eligible_leader_replicas
231                .as_deref()
232                .unwrap_or_default(),
233        ),
234        last_known_elr: node_list(
235            nodes,
236            partition.last_known_elr.as_deref().unwrap_or_default(),
237        ),
238    }
239}
240
241/// The description of one `DescribeTopicPartitions` topic, as Kafka's
242/// `getTopicDescriptionFromDescribeTopicsResponseTopic`.
243fn description_from_describe(
244    topic: &DescribeTopicPartitionsResponseTopic,
245    name: String,
246    nodes: &BTreeMap<i32, ClusterNode>,
247    include_authorized_operations: bool,
248) -> TopicDescription {
249    TopicDescription {
250        name,
251        topic_id: topic_id(topic.topic_id),
252        is_internal: topic.is_internal,
253        partitions: topic
254            .partitions
255            .iter()
256            .map(|partition| partition_from_describe(partition, nodes))
257            .collect(),
258        authorized_operations: if include_authorized_operations {
259            authorized_operations(topic.topic_authorized_operations)
260        } else {
261            None
262        },
263    }
264}
265
266/// The paging state of one `describeTopics` by name.
267#[derive(Debug, Default)]
268struct DescribePages {
269    /// The topics that no answer has finished.
270    pending: BTreeSet<String>,
271    /// The topic that the last answer cut, with the partitions read so far.
272    partial: Option<TopicDescription>,
273    /// The finished topics.
274    out: TopicDescriptions<String>,
275}
276
277impl DescribePages {
278    /// Reads one answer, as Kafka's `handleResponse` does: a topic error
279    /// finishes the topic, the cursor topic waits for the next page, and the
280    /// partial topic of the previous page gets the new partitions.
281    fn read(
282        &mut self,
283        response: DescribeTopicPartitionsResponse,
284        nodes: &BTreeMap<i32, ClusterNode>,
285        include_authorized_operations: bool,
286    ) {
287        let mut cursor = response.next_cursor.map(|cursor| cursor.topic_name);
288        let mut next = None;
289        for topic in response.topics {
290            let Some(name) = topic.name.clone() else {
291                tracing::warn!("the DescribeTopicPartitions response names a topic without a name");
292                continue;
293            };
294            let asked = self.pending.contains(&name)
295                || self.partial.as_ref().is_some_and(|p| p.name == name);
296            if !asked {
297                tracing::warn!(
298                    topic = %name,
299                    "the DescribeTopicPartitions response names a topic that is not in the request"
300                );
301                continue;
302            }
303            if topic.error_code != 0 {
304                self.out.entry(name.clone()).or_insert_with(|| {
305                    Err(KafkaError {
306                        code: topic.error_code,
307                        name: kafka_error_name(topic.error_code),
308                        message: None,
309                    })
310                });
311                self.pending.remove(&name);
312                if cursor.as_deref() == Some(name.as_str()) {
313                    cursor = None;
314                }
315                continue;
316            }
317            let current = description_from_describe(
318                &topic,
319                name.clone(),
320                nodes,
321                include_authorized_operations,
322            );
323            if let Some(partial) = self.partial.as_mut()
324                && partial.name == name
325            {
326                partial.partitions.extend(current.partitions);
327                continue;
328            }
329            if cursor.as_deref() == Some(name.as_str()) {
330                next = Some(current);
331                continue;
332            }
333            self.pending.remove(&name);
334            self.out.entry(name).or_insert(Ok(current));
335        }
336        if let Some(partial) = self
337            .partial
338            .take_if(|partial| cursor.as_deref() != Some(partial.name.as_str()))
339        {
340            self.pending.remove(&partial.name);
341            self.out.entry(partial.name.clone()).or_insert(Ok(partial));
342        }
343        if next.is_some() {
344            self.partial = next;
345        }
346    }
347
348    /// Whether another page is needed.
349    fn unfinished(&self) -> bool {
350        !self.pending.is_empty() || self.partial.is_some()
351    }
352
353    /// Fails every unfinished topic with `error`.
354    fn fail(mut self, error: &KafkaError) -> TopicDescriptions<String> {
355        let unfinished = mem::take(&mut self.pending)
356            .into_iter()
357            .chain(self.partial.take().map(|partial| partial.name));
358        for name in unfinished {
359            self.out.entry(name).or_insert_with(|| Err(error.clone()));
360        }
361        self.out
362    }
363}
364
365/// The `Metadata` request of `describeTopics` by name, as Kafka's
366/// `generateDescribeTopicsCallWithMetadataApi` builds it.
367fn metadata_by_name_request(
368    names: &BTreeSet<String>,
369    options: DescribeTopicsOptions,
370) -> MetadataRequest {
371    MetadataRequest {
372        topics: Some(
373            names
374                .iter()
375                .map(|name| MetadataRequestTopic {
376                    topic_id: ProtoUuid::ZERO,
377                    name: Some(name.clone()),
378                    ..Default::default()
379                })
380                .collect(),
381        ),
382        allow_auto_topic_creation: false,
383        include_topic_authorized_operations: options.include_authorized_operations,
384        ..Default::default()
385    }
386}
387
388/// The `Metadata` request of `describeTopics` by ID, as Kafka's
389/// `handleDescribeTopicsByIds` builds it.
390fn metadata_by_id_request(ids: &BTreeSet<Uuid>, options: DescribeTopicsOptions) -> MetadataRequest {
391    MetadataRequest {
392        topics: Some(
393            ids.iter()
394                .map(|id| MetadataRequestTopic {
395                    topic_id: ProtoUuid(*id.as_bytes()),
396                    name: None,
397                    ..Default::default()
398                })
399                .collect(),
400        ),
401        allow_auto_topic_creation: false,
402        include_topic_authorized_operations: options.include_authorized_operations,
403        ..Default::default()
404    }
405}
406
407fn metadata_nodes(response: &MetadataResponse) -> BTreeMap<i32, ClusterNode> {
408    response
409        .brokers
410        .iter()
411        .map(|broker| {
412            (
413                broker.node_id,
414                ClusterNode {
415                    id: broker.node_id,
416                    host: broker.host.clone(),
417                    port: broker.port,
418                    rack: broker.rack.clone(),
419                    is_fenced: false,
420                },
421            )
422        })
423        .collect()
424}
425
426fn partition_from_metadata(
427    partition: &MetadataResponsePartition,
428    nodes: &BTreeMap<i32, ClusterNode>,
429) -> TopicPartitionInfo {
430    TopicPartitionInfo {
431        partition: partition.partition_index,
432        leader: (partition.leader_id >= 0)
433            .then(|| nodes.get(&partition.leader_id).cloned())
434            .flatten(),
435        replicas: node_list(nodes, &partition.replica_nodes),
436        isr: node_list(nodes, &partition.isr_nodes),
437        elr: Vec::new(),
438        last_known_elr: Vec::new(),
439    }
440}
441
442/// The description of one `Metadata` topic, as Kafka's
443/// `getTopicDescriptionFromCluster`: partitions in partition order, and the
444/// authorized operations whenever the answer carries them.
445fn description_from_metadata(
446    topic: &MetadataResponseTopic,
447    name: String,
448    nodes: &BTreeMap<i32, ClusterNode>,
449) -> TopicDescription {
450    let mut partitions = topic
451        .partitions
452        .iter()
453        .map(|partition| partition_from_metadata(partition, nodes))
454        .collect::<Vec<_>>();
455    partitions.sort_by_key(|partition| partition.partition);
456    TopicDescription {
457        name,
458        topic_id: topic_id(topic.topic_id),
459        is_internal: topic.is_internal,
460        partitions,
461        authorized_operations: authorized_operations(topic.topic_authorized_operations),
462    }
463}
464
465/// The description of each topic of `names` in a `Metadata` answer, as
466/// Kafka's `generateDescribeTopicsCallWithMetadataApi` `handleResponse`
467/// reads it: a topic error first, then `UNKNOWN_TOPIC_OR_PARTITION` for a
468/// topic the answer does not name.
469fn descriptions_by_name(
470    names: &BTreeSet<String>,
471    response: MetadataResponse,
472) -> TopicDescriptions<String> {
473    let nodes = metadata_nodes(&response);
474    let mut topics = response
475        .topics
476        .into_iter()
477        .filter_map(|topic| Some((topic.name.clone()?, topic)))
478        .collect::<BTreeMap<_, _>>();
479    names
480        .iter()
481        .map(|name| {
482            let result = match topics.remove(name) {
483                Some(topic) if topic.error_code != 0 => Err(KafkaError {
484                    code: topic.error_code,
485                    name: kafka_error_name(topic.error_code),
486                    message: None,
487                }),
488                Some(topic) => Ok(description_from_metadata(&topic, name.clone(), &nodes)),
489                None => Err(topic_error(
490                    UNKNOWN_TOPIC_OR_PARTITION,
491                    format!("Topic {name} not found."),
492                )),
493            };
494            (name.clone(), result)
495        })
496        .collect()
497}
498
499/// The description of each topic of `ids` in a `Metadata` answer, as
500/// Kafka's `handleDescribeTopicsByIds` `handleResponse` reads it. Kafka's
501/// `Cluster` holds only topics without an error, so an ID that the answer
502/// does not name without an error gives `UNKNOWN_TOPIC_ID`.
503fn descriptions_by_id(ids: &BTreeSet<Uuid>, response: MetadataResponse) -> TopicDescriptions<Uuid> {
504    let nodes = metadata_nodes(&response);
505    let mut topics = response
506        .topics
507        .into_iter()
508        .filter(|topic| topic.error_code == 0)
509        .filter_map(|topic| Some((topic_id(topic.topic_id)?, topic)))
510        .collect::<BTreeMap<_, _>>();
511    ids.iter()
512        .map(|id| {
513            let result = topics
514                .remove(id)
515                .and_then(|mut topic| {
516                    let name = topic.name.take()?;
517                    Some(description_from_metadata(&topic, name, &nodes))
518                })
519                .ok_or_else(|| topic_error(UNKNOWN_TOPIC_ID, format!("TopicId {id} not found.")));
520            (*id, result)
521        })
522        .collect()
523}
524
525/// Whether `error` means that the peer does not support the request's
526/// version, as Kafka's `UnsupportedVersionException`.
527const fn is_unsupported_version(error: &AdminError) -> bool {
528    matches!(
529        error,
530        AdminError::Transport(ClientError::IncompatibleVersion { .. })
531    )
532}
533
534impl AdminClient {
535    /// Lists the topics of the cluster, as Kafka's `listTopics` operation
536    /// does: one `Metadata` request for all topics, keeping internal topics
537    /// only with [`ListTopicsOptions::list_internal`].
538    ///
539    /// # Errors
540    /// Returns a transport error, and [`AdminError::Broker`] with
541    /// `REQUEST_TIMED_OUT` (7) at `default.api.timeout.ms` (60 s).
542    pub async fn list_topics(
543        &self,
544        options: ListTopicsOptions,
545    ) -> Result<BTreeMap<String, TopicListing>, AdminError> {
546        let retry = ControllerRetry::new("Metadata", self.retry);
547        let response = retry
548            .bounded(self.conn.send_at_least(all_topics_request(), 1))
549            .await?;
550        Ok(topic_listings(response, options))
551    }
552
553    /// Describes each topic of `names`, as Kafka's `describeTopics`
554    /// operation does for a `TopicNameCollection`.
555    ///
556    /// The client reads the nodes of the cluster with `DescribeCluster`,
557    /// then sends `DescribeTopicPartitions` (KIP-966) and follows its cursor
558    /// until every topic is complete, at most
559    /// [`DescribeTopicsOptions::partition_size_limit_per_response`]
560    /// partitions per answer. When the peer does not support
561    /// `DescribeTopicPartitions`, such as a controller reached through
562    /// controller bootstrap, it sends `Metadata` instead.
563    ///
564    /// The result has one entry for each name:
565    ///
566    /// - The description of the topic.
567    /// - `INVALID_TOPIC_EXCEPTION` (17) for an empty name, which Kafka
568    ///   cannot send.
569    /// - The topic error of the answer, such as `UNKNOWN_TOPIC_OR_PARTITION`
570    ///   (3) or `TOPIC_AUTHORIZATION_FAILED` (29).
571    /// - The error of the call for every topic it did not finish, such as a
572    ///   failed `DescribeCluster`, a transport error, or `REQUEST_TIMED_OUT`
573    ///   (7) at `default.api.timeout.ms` (60 s).
574    pub async fn describe_topics(
575        &self,
576        names: &[&str],
577        options: DescribeTopicsOptions,
578    ) -> TopicDescriptions<String> {
579        let mut pages = DescribePages::default();
580        for name in names {
581            if name.is_empty() {
582                pages.out.insert(
583                    String::new(),
584                    Err(topic_error(
585                        INVALID_TOPIC_EXCEPTION,
586                        "The given topic name '' cannot be represented in a request.".to_owned(),
587                    )),
588                );
589            } else {
590                pages.pending.insert((*name).to_owned());
591            }
592        }
593        if pages.pending.is_empty() {
594            return pages.out;
595        }
596        let nodes = match self
597            .describe_cluster(DescribeClusterOptions::builder().build())
598            .await
599        {
600            Ok(cluster) => cluster
601                .nodes
602                .into_iter()
603                .map(|node| (node.id, node))
604                .collect::<BTreeMap<_, _>>(),
605            Err(error) => return pages.fail(&list_groups_kafka_error(&error)),
606        };
607        if self
608            .conn
609            .advertised_api_range(DescribeTopicPartitionsRequest::API_KEY)
610            .await
611            .is_none()
612        {
613            return self.describe_topics_with_metadata(pages, options).await;
614        }
615        let retry = ControllerRetry::new("DescribeTopicPartitions", self.retry);
616        while pages.unfinished() {
617            let request =
618                describe_partitions_request(&pages.pending, pages.partial.as_ref(), options);
619            match retry.bounded(self.conn.send(request)).await {
620                Ok(response) => pages.read(response, &nodes, options.include_authorized_operations),
621                Err(error) if is_unsupported_version(&error) => {
622                    return self.describe_topics_with_metadata(pages, options).await;
623                }
624                Err(error) => return pages.fail(&list_groups_kafka_error(&error)),
625            }
626        }
627        pages.out
628    }
629
630    /// The `Metadata` fallback of [`Self::describe_topics`] for the topics
631    /// that `pages` has not finished. A peer below `Metadata` v4 cannot
632    /// refuse auto-creation, so it gets a request for all topics, as
633    /// Kafka's `supportsDisablingTopicCreation` fallback sends.
634    async fn describe_topics_with_metadata(
635        &self,
636        mut pages: DescribePages,
637        options: DescribeTopicsOptions,
638    ) -> TopicDescriptions<String> {
639        if let Some(partial) = pages.partial.take() {
640            pages.pending.insert(partial.name);
641        }
642        let retry = ControllerRetry::new("Metadata", self.retry);
643        let request = metadata_by_name_request(&pages.pending, options);
644        let response = match retry.bounded(self.conn.send_at_least(request, 4)).await {
645            Err(error) if is_unsupported_version(&error) => {
646                retry
647                    .bounded(self.conn.send_at_least(all_topics_request(), 1))
648                    .await
649            }
650            other => other,
651        };
652        match response {
653            Ok(response) => {
654                let pending = mem::take(&mut pages.pending);
655                for (name, result) in descriptions_by_name(&pending, response) {
656                    pages.out.entry(name).or_insert(result);
657                }
658                pages.out
659            }
660            Err(error) => pages.fail(&list_groups_kafka_error(&error)),
661        }
662    }
663
664    /// Describes each topic of `ids`, as Kafka's `describeTopics` operation
665    /// does for a `TopicIdCollection`: one `Metadata` request (v12 or
666    /// higher, which Kafka needs for topic IDs).
667    ///
668    /// The result has one entry for each ID: the description of the topic,
669    /// `INVALID_TOPIC_EXCEPTION` (17) for the nil ID, `UNKNOWN_TOPIC_ID`
670    /// (100) for an ID that the answer does not describe, or the error of
671    /// the call, such as `UNSUPPORTED_VERSION` (35) for a peer below
672    /// `Metadata` v12.
673    pub async fn describe_topics_by_id(
674        &self,
675        ids: &[Uuid],
676        options: DescribeTopicsOptions,
677    ) -> TopicDescriptions<Uuid> {
678        let mut out = TopicDescriptions::new();
679        let mut pending = BTreeSet::new();
680        for id in ids {
681            if id.is_nil() {
682                out.insert(
683                    *id,
684                    Err(topic_error(
685                        INVALID_TOPIC_EXCEPTION,
686                        format!("The given topic id '{id}' cannot be represented in a request."),
687                    )),
688                );
689            } else {
690                pending.insert(*id);
691            }
692        }
693        if pending.is_empty() {
694            return out;
695        }
696        let retry = ControllerRetry::new("Metadata", self.retry);
697        let request = metadata_by_id_request(&pending, options);
698        match retry.bounded(self.conn.send_at_least(request, 12)).await {
699            Ok(response) => out.extend(descriptions_by_id(&pending, response)),
700            Err(error) => {
701                let error = list_groups_kafka_error(&error);
702                out.extend(pending.into_iter().map(|id| (id, Err(error.clone()))));
703            }
704        }
705        out
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use std::{
712        net::SocketAddr,
713        sync::{Arc, Mutex},
714    };
715
716    use krabka_client_core::MockReply;
717    use krabka_protocol::owned::{
718        describe_cluster_request,
719        describe_cluster_response::{DescribeClusterBroker, DescribeClusterResponse},
720        describe_topic_partitions_request,
721        describe_topic_partitions_response::Cursor as ResponseCursor,
722        metadata_request,
723        metadata_response::MetadataResponseBroker,
724    };
725
726    use super::*;
727    use crate::partition_leaders::test_support::{
728        ApiVersionRange, decode_request, encode_response, fast_admin, scripted_broker,
729    };
730
731    const ALPHA: Uuid = Uuid::from_u128(0xa);
732    const BETA: Uuid = Uuid::from_u128(0xb);
733
734    fn id_of(name: &str) -> Uuid {
735        if name == "alpha" { ALPHA } else { BETA }
736    }
737
738    /// Broker 1 at `own`, the only node that the cluster names.
739    fn broker_one(own: SocketAddr) -> ClusterNode {
740        ClusterNode {
741            id: 1,
742            host: own.ip().to_string(),
743            port: i32::from(own.port()),
744            rack: None,
745            is_fenced: false,
746        }
747    }
748
749    /// Broker 2, which the cluster does not name: Kafka's placeholder node.
750    fn unknown_two() -> ClusterNode {
751        ClusterNode {
752            id: 2,
753            host: String::new(),
754            port: -1,
755            rack: None,
756            is_fenced: false,
757        }
758    }
759
760    /// Partition `index` as `DescribeTopicPartitions` gives it: leader 1,
761    /// replicas 1 and 2, ISR 1, ELR 2.
762    fn page_partition(index: i32) -> DescribeTopicPartitionsResponsePartition {
763        DescribeTopicPartitionsResponsePartition {
764            partition_index: index,
765            leader_id: 1,
766            replica_nodes: vec![1, 2],
767            isr_nodes: vec![1],
768            eligible_leader_replicas: Some(vec![2]),
769            last_known_elr: Some(Vec::new()),
770            ..Default::default()
771        }
772    }
773
774    /// A `DescribeTopicPartitions` topic whose authorized operations are
775    /// `READ` (bit 3).
776    fn page_topic(
777        name: &str,
778        error_code: i16,
779        partitions: &[i32],
780    ) -> DescribeTopicPartitionsResponseTopic {
781        DescribeTopicPartitionsResponseTopic {
782            error_code,
783            name: Some(name.to_owned()),
784            topic_id: ProtoUuid(*id_of(name).as_bytes()),
785            partitions: partitions.iter().copied().map(page_partition).collect(),
786            topic_authorized_operations: 1 << 3,
787            ..Default::default()
788        }
789    }
790
791    fn page(
792        topics: Vec<DescribeTopicPartitionsResponseTopic>,
793        cursor: Option<(&str, i32)>,
794    ) -> DescribeTopicPartitionsResponse {
795        DescribeTopicPartitionsResponse {
796            topics,
797            next_cursor: cursor.map(|(topic_name, partition_index)| ResponseCursor {
798                topic_name: topic_name.to_owned(),
799                partition_index,
800                ..Default::default()
801            }),
802            ..Default::default()
803        }
804    }
805
806    /// The description that `DescribeTopicPartitions` pages give `name`.
807    fn paged(name: &str, partitions: &[i32], own: SocketAddr) -> TopicDescription {
808        TopicDescription {
809            name: name.to_owned(),
810            topic_id: Some(id_of(name)),
811            is_internal: false,
812            partitions: partitions
813                .iter()
814                .map(|partition| TopicPartitionInfo {
815                    partition: *partition,
816                    leader: Some(broker_one(own)),
817                    replicas: vec![broker_one(own), unknown_two()],
818                    isr: vec![broker_one(own)],
819                    elr: vec![unknown_two()],
820                    last_known_elr: Vec::new(),
821                })
822                .collect(),
823            authorized_operations: None,
824        }
825    }
826
827    fn dtp_request(topics: &[&str], cursor: Option<(&str, i32)>) -> DescribeTopicPartitionsRequest {
828        DescribeTopicPartitionsRequest {
829            topics: topics
830                .iter()
831                .map(|name| TopicRequest {
832                    name: (*name).to_owned(),
833                    ..Default::default()
834                })
835                .collect(),
836            response_partition_limit: 2000,
837            cursor: cursor.map(|(topic_name, partition_index)| RequestCursor {
838                topic_name: topic_name.to_owned(),
839                partition_index,
840                ..Default::default()
841            }),
842            ..Default::default()
843        }
844    }
845
846    /// A `Metadata` topic with partitions `indexes` in that order: leader
847    /// `leader`, replicas 1 and 2, ISR 1.
848    fn metadata_topic(
849        name: &str,
850        error_code: i16,
851        indexes: &[i32],
852        leader: i32,
853    ) -> MetadataResponseTopic {
854        MetadataResponseTopic {
855            error_code,
856            name: Some(name.to_owned()),
857            topic_id: ProtoUuid(*id_of(name).as_bytes()),
858            is_internal: name.starts_with("__"),
859            partitions: indexes
860                .iter()
861                .map(|index| MetadataResponsePartition {
862                    partition_index: *index,
863                    leader_id: leader,
864                    replica_nodes: vec![1, 2],
865                    isr_nodes: vec![1],
866                    ..Default::default()
867                })
868                .collect(),
869            ..Default::default()
870        }
871    }
872
873    fn metadata(own: SocketAddr, topics: Vec<MetadataResponseTopic>) -> MetadataResponse {
874        MetadataResponse {
875            brokers: vec![MetadataResponseBroker {
876                node_id: 1,
877                host: own.ip().to_string(),
878                port: i32::from(own.port()),
879                ..Default::default()
880            }],
881            controller_id: 1,
882            topics,
883            ..Default::default()
884        }
885    }
886
887    /// The description that `Metadata` gives `name`, partitions sorted.
888    fn from_metadata(
889        name: &str,
890        partitions: &[i32],
891        own: SocketAddr,
892        leader: bool,
893    ) -> TopicDescription {
894        TopicDescription {
895            name: name.to_owned(),
896            topic_id: Some(id_of(name)),
897            is_internal: false,
898            partitions: partitions
899                .iter()
900                .map(|partition| TopicPartitionInfo {
901                    partition: *partition,
902                    leader: leader.then(|| broker_one(own)),
903                    replicas: vec![broker_one(own), unknown_two()],
904                    isr: vec![broker_one(own)],
905                    elr: Vec::new(),
906                    last_known_elr: Vec::new(),
907                })
908                .collect(),
909            authorized_operations: None,
910        }
911    }
912
913    fn topic_err(code: i16, message: Option<String>) -> Result<TopicDescription, KafkaError> {
914        Err(KafkaError {
915            code,
916            name: kafka_error_name(code),
917            message,
918        })
919    }
920
921    /// What the mock broker of one case answers to `DescribeTopicPartitions`.
922    #[derive(Clone)]
923    enum Answer {
924        Page(DescribeTopicPartitionsResponse),
925        Close,
926    }
927
928    /// What one mock broker received.
929    #[derive(Debug, Clone, PartialEq)]
930    enum Received {
931        Pages(DescribeTopicPartitionsRequest),
932        Metadata(i16, MetadataRequest),
933    }
934
935    /// One case of [`describe_topics_pages_and_falls_back_to_metadata`].
936    struct DescribeCase {
937        name: &'static str,
938        names: Vec<&'static str>,
939        cluster_error: i16,
940        dtp: bool,
941        answers: Vec<Answer>,
942        metadata_topics: Vec<MetadataResponseTopic>,
943        expected: fn(SocketAddr) -> (TopicDescriptions<String>, Vec<Received>),
944    }
945
946    /// Runs `describe_topics` against a broker that answers
947    /// `DescribeCluster` with the case's error and broker 1, each
948    /// `DescribeTopicPartitions` request with the next answer (when the case
949    /// advertises the API), and `Metadata` with the case's topics.
950    async fn describe_case(
951        case: &DescribeCase,
952    ) -> (TopicDescriptions<String>, Vec<Received>, SocketAddr) {
953        let received = Arc::new(Mutex::new(Vec::new()));
954        let handler_received = Arc::clone(&received);
955        let mut apis = vec![
956            ApiVersionRange::builder()
957                .api_key(describe_cluster_request::API_KEY)
958                .max_version(2)
959                .build(),
960        ];
961        if case.dtp {
962            apis.push(
963                ApiVersionRange::builder()
964                    .api_key(describe_topic_partitions_request::API_KEY)
965                    .build(),
966            );
967        }
968        let cluster_error = case.cluster_error;
969        let metadata_topics = case.metadata_topics.clone();
970        let pages = Arc::new(Mutex::new(case.answers.clone().into_iter()));
971        let broker = scripted_broker(apis, move |api_key, version, body, own| {
972            let mut received = handler_received.lock().expect("received lock");
973            let reply = match api_key {
974                describe_cluster_request::API_KEY => encode_response(
975                    &DescribeClusterResponse {
976                        error_code: cluster_error,
977                        controller_id: 1,
978                        brokers: vec![DescribeClusterBroker {
979                            broker_id: 1,
980                            host: own.ip().to_string(),
981                            port: i32::from(own.port()),
982                            ..Default::default()
983                        }],
984                        ..Default::default()
985                    },
986                    version,
987                    true,
988                ),
989                describe_topic_partitions_request::API_KEY => {
990                    received.push(Received::Pages(decode_request(body, version, true)));
991                    let answer = pages.lock().expect("pages lock").next();
992                    match answer {
993                        Some(Answer::Page(page)) => encode_response(&page, version, true),
994                        Some(Answer::Close) => return MockReply::Close,
995                        None => return MockReply::Silent,
996                    }
997                }
998                metadata_request::API_KEY => {
999                    let flexible = version >= metadata_request::FLEXIBLE_MIN;
1000                    received.push(Received::Metadata(
1001                        version,
1002                        decode_request(body, version, flexible),
1003                    ));
1004                    encode_response(&metadata(own, metadata_topics.clone()), version, flexible)
1005                }
1006                _ => return MockReply::Silent,
1007            };
1008            drop(received);
1009            MockReply::Respond(reply)
1010        })
1011        .await;
1012        let admin = fast_admin(broker.addr, krabka_units::secs(5)).await;
1013        let result = admin
1014            .describe_topics(&case.names, DescribeTopicsOptions::builder().build())
1015            .await;
1016        let own = broker.addr;
1017        broker.stop();
1018        let received = received.lock().expect("received lock").clone();
1019        (result, received, own)
1020    }
1021
1022    /// Kafka's `describeTopics` by name pages through
1023    /// `DescribeTopicPartitions` with its cursor, maps each replica to its
1024    /// node, fails a topic with its error, retries a lost connection, falls
1025    /// back to `Metadata` when the peer lacks the API, and fails every topic
1026    /// when `DescribeCluster` fails.
1027    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1028    async fn describe_topics_pages_and_falls_back_to_metadata() {
1029        let cases = [
1030            DescribeCase {
1031                name: "one page",
1032                names: vec!["beta", "alpha"],
1033                cluster_error: 0,
1034                dtp: true,
1035                answers: vec![Answer::Page(page(
1036                    vec![page_topic("alpha", 0, &[0]), page_topic("beta", 0, &[0, 1])],
1037                    None,
1038                ))],
1039                metadata_topics: Vec::new(),
1040                expected: |own| {
1041                    (
1042                        maplit::btreemap! {
1043                            "alpha".to_owned() => Ok(paged("alpha", &[0], own)),
1044                            "beta".to_owned() => Ok(paged("beta", &[0, 1], own)),
1045                        },
1046                        vec![Received::Pages(dtp_request(&["alpha", "beta"], None))],
1047                    )
1048                },
1049            },
1050            DescribeCase {
1051                name: "the cursor splits a topic across pages",
1052                names: vec!["alpha", "beta"],
1053                cluster_error: 0,
1054                dtp: true,
1055                answers: vec![
1056                    Answer::Page(page(
1057                        vec![page_topic("alpha", 0, &[0]), page_topic("beta", 0, &[0])],
1058                        Some(("beta", 1)),
1059                    )),
1060                    Answer::Page(page(vec![page_topic("beta", 0, &[1, 2])], None)),
1061                ],
1062                metadata_topics: Vec::new(),
1063                expected: |own| {
1064                    (
1065                        maplit::btreemap! {
1066                            "alpha".to_owned() => Ok(paged("alpha", &[0], own)),
1067                            "beta".to_owned() => Ok(paged("beta", &[0, 1, 2], own)),
1068                        },
1069                        vec![
1070                            Received::Pages(dtp_request(&["alpha", "beta"], None)),
1071                            Received::Pages(dtp_request(&["beta"], Some(("beta", 1)))),
1072                        ],
1073                    )
1074                },
1075            },
1076            DescribeCase {
1077                name: "a topic error fails that topic only",
1078                names: vec!["alpha", "beta"],
1079                cluster_error: 0,
1080                dtp: true,
1081                answers: vec![Answer::Page(page(
1082                    vec![page_topic("alpha", 29, &[]), page_topic("beta", 0, &[0])],
1083                    None,
1084                ))],
1085                metadata_topics: Vec::new(),
1086                expected: |own| {
1087                    (
1088                        maplit::btreemap! {
1089                            "alpha".to_owned() => topic_err(29, None),
1090                            "beta".to_owned() => Ok(paged("beta", &[0], own)),
1091                        },
1092                        vec![Received::Pages(dtp_request(&["alpha", "beta"], None))],
1093                    )
1094                },
1095            },
1096            DescribeCase {
1097                name: "a lost connection is retried",
1098                names: vec!["alpha"],
1099                cluster_error: 0,
1100                dtp: true,
1101                answers: vec![
1102                    Answer::Close,
1103                    Answer::Page(page(vec![page_topic("alpha", 0, &[0])], None)),
1104                ],
1105                metadata_topics: Vec::new(),
1106                expected: |own| {
1107                    (
1108                        maplit::btreemap! {
1109                            "alpha".to_owned() => Ok(paged("alpha", &[0], own)),
1110                        },
1111                        vec![
1112                            Received::Pages(dtp_request(&["alpha"], None)),
1113                            Received::Pages(dtp_request(&["alpha"], None)),
1114                        ],
1115                    )
1116                },
1117            },
1118            DescribeCase {
1119                name: "a peer without DescribeTopicPartitions is asked with Metadata",
1120                names: vec!["alpha", "beta", ""],
1121                cluster_error: 0,
1122                dtp: false,
1123                answers: Vec::new(),
1124                metadata_topics: vec![metadata_topic("alpha", 0, &[1, 0], -1)],
1125                expected: |own| {
1126                    (
1127                        maplit::btreemap! {
1128                            String::new() => topic_err(
1129                            17,
1130                            Some(
1131                                "The given topic name '' cannot be represented in a \
1132                                 request."
1133                                    .to_owned(),
1134                            ),
1135                        ),
1136                            "alpha".to_owned() => Ok(from_metadata("alpha", &[0, 1], own, false)),
1137                            "beta".to_owned() => topic_err(3, Some("Topic beta not found.".to_owned())),
1138                        },
1139                        vec![Received::Metadata(
1140                            12,
1141                            metadata_by_name_request(
1142                                &maplit::btreeset! {
1143                                    "alpha".to_owned(),
1144                                    "beta".to_owned(),
1145                                },
1146                                DescribeTopicsOptions::builder().build(),
1147                            ),
1148                        )],
1149                    )
1150                },
1151            },
1152            DescribeCase {
1153                name: "a failed DescribeCluster fails every topic",
1154                names: vec!["alpha"],
1155                cluster_error: 31,
1156                dtp: true,
1157                answers: Vec::new(),
1158                metadata_topics: Vec::new(),
1159                expected: |_| {
1160                    (
1161                        maplit::btreemap! {
1162                            "alpha".to_owned() => topic_err(31, None),
1163                        },
1164                        Vec::new(),
1165                    )
1166                },
1167            },
1168        ];
1169        for case in &cases {
1170            let (result, received, own) = describe_case(case).await;
1171            assert2::assert!(
1172                (result, received) == (case.expected)(own),
1173                "case {}",
1174                case.name
1175            );
1176        }
1177    }
1178
1179    /// Kafka's `listTopics` asks `Metadata` for all topics, keeps internal
1180    /// topics only with `listInternal`, and retries a lost connection.
1181    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1182    async fn list_topics_keeps_internal_topics_on_request() {
1183        let listing = |name: &str, is_internal| TopicListing {
1184            name: name.to_owned(),
1185            topic_id: Some(id_of(name)),
1186            is_internal,
1187        };
1188        for (name, list_internal, close_first, expected) in [
1189            (
1190                "without internal topics",
1191                false,
1192                false,
1193                (
1194                    maplit::btreemap! {
1195                        "alpha".to_owned() => listing("alpha", false),
1196                    },
1197                    1,
1198                ),
1199            ),
1200            (
1201                "with internal topics",
1202                true,
1203                false,
1204                (
1205                    maplit::btreemap! {
1206                        "__consumer_offsets".to_owned() => listing("__consumer_offsets", true),
1207                        "alpha".to_owned() => listing("alpha", false),
1208                    },
1209                    1,
1210                ),
1211            ),
1212            (
1213                "a lost connection is retried",
1214                false,
1215                true,
1216                (
1217                    maplit::btreemap! {
1218                        "alpha".to_owned() => listing("alpha", false),
1219                    },
1220                    2,
1221                ),
1222            ),
1223        ] {
1224            let requests = Arc::new(Mutex::new(Vec::new()));
1225            let handler_requests = Arc::clone(&requests);
1226            let broker = scripted_broker(Vec::new(), move |api_key, version, body, own| {
1227                if api_key != metadata_request::API_KEY {
1228                    return MockReply::Silent;
1229                }
1230                let mut requests = handler_requests.lock().expect("requests lock");
1231                let request: MetadataRequest = decode_request(body, version, true);
1232                requests.push(request);
1233                let first = requests.len() == 1;
1234                drop(requests);
1235                if close_first && first {
1236                    return MockReply::Close;
1237                }
1238                MockReply::Respond(encode_response(
1239                    &metadata(
1240                        own,
1241                        vec![
1242                            metadata_topic("alpha", 0, &[0], 1),
1243                            metadata_topic("__consumer_offsets", 0, &[0], 1),
1244                        ],
1245                    ),
1246                    version,
1247                    true,
1248                ))
1249            })
1250            .await;
1251            let admin = fast_admin(broker.addr, krabka_units::secs(5)).await;
1252            let result = admin
1253                .list_topics(
1254                    ListTopicsOptions::builder()
1255                        .list_internal(list_internal)
1256                        .build(),
1257                )
1258                .await
1259                .expect("list topics");
1260            broker.stop();
1261            let requests = requests.lock().expect("requests lock").clone();
1262            assert2::assert!(
1263                (result, requests) == (expected.0, vec![all_topics_request(); expected.1]),
1264                "case {name}"
1265            );
1266        }
1267    }
1268
1269    /// Kafka's `describeTopics` by ID sends `Metadata` v12 or higher with
1270    /// the IDs, gives `UNKNOWN_TOPIC_ID` to an ID it does not find or that
1271    /// has an error, refuses the nil ID, and fails every ID on a peer below
1272    /// v12.
1273    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1274    async fn describe_topics_by_id_uses_metadata_v12() {
1275        let nil = topic_err(
1276            17,
1277            Some(format!(
1278                "The given topic id '{}' cannot be represented in a request.",
1279                Uuid::nil()
1280            )),
1281        );
1282        let unknown_beta = topic_err(100, Some(format!("TopicId {BETA} not found.")));
1283        let unsupported = topic_err(35, None);
1284        let options = DescribeTopicsOptions::builder()
1285            .include_authorized_operations(true)
1286            .build();
1287        let asked = maplit::btreeset! {
1288            ALPHA,
1289            BETA,
1290        };
1291        for (name, max_metadata, topics, expected_requests) in [
1292            (
1293                "found and missing",
1294                12,
1295                vec![metadata_topic("alpha", 0, &[0], 1)],
1296                vec![(12, metadata_by_id_request(&asked, options))],
1297            ),
1298            (
1299                "a topic with an error is unknown",
1300                13,
1301                vec![
1302                    metadata_topic("alpha", 0, &[0], 1),
1303                    metadata_topic("beta", 29, &[], -1),
1304                ],
1305                vec![(13, metadata_by_id_request(&asked, options))],
1306            ),
1307            ("a peer below v12", 11, Vec::new(), Vec::new()),
1308        ] {
1309            let requests = Arc::new(Mutex::new(Vec::new()));
1310            let handler_requests = Arc::clone(&requests);
1311            let broker = scripted_broker(
1312                vec![
1313                    ApiVersionRange::builder()
1314                        .api_key(metadata_request::API_KEY)
1315                        .min_version(1)
1316                        .max_version(max_metadata)
1317                        .build(),
1318                ],
1319                move |api_key, version, body, own| {
1320                    if api_key != metadata_request::API_KEY {
1321                        return MockReply::Silent;
1322                    }
1323                    let request: MetadataRequest = decode_request(body, version, true);
1324                    handler_requests
1325                        .lock()
1326                        .expect("requests lock")
1327                        .push((version, request));
1328                    MockReply::Respond(encode_response(
1329                        &metadata(own, topics.clone()),
1330                        version,
1331                        true,
1332                    ))
1333                },
1334            )
1335            .await;
1336            let admin = fast_admin(broker.addr, krabka_units::secs(5)).await;
1337            let result = admin
1338                .describe_topics_by_id(&[BETA, Uuid::nil(), ALPHA], options)
1339                .await
1340                .into_iter()
1341                .map(|(id, result)| {
1342                    // A version error names the version ranges in its
1343                    // message; the code is what the case checks.
1344                    let result = result.map_err(|error| KafkaError {
1345                        message: error.message.filter(|_| error.code != 35),
1346                        ..error
1347                    });
1348                    (id, result)
1349                })
1350                .collect::<BTreeMap<_, _>>();
1351            let own = broker.addr;
1352            broker.stop();
1353            let expected = if max_metadata < 12 {
1354                maplit::btreemap! {
1355                    Uuid::nil() => nil.clone(),
1356                    ALPHA => unsupported.clone(),
1357                    BETA => unsupported.clone(),
1358                }
1359            } else {
1360                maplit::btreemap! {
1361                    Uuid::nil() => nil.clone(),
1362                    ALPHA => Ok(from_metadata("alpha", &[0], own, true)),
1363                    BETA => unknown_beta.clone(),
1364                }
1365            };
1366            assert2::assert!(
1367                (result, requests.lock().expect("requests lock").clone())
1368                    == (expected, expected_requests),
1369                "case {name}"
1370            );
1371        }
1372    }
1373
1374    /// Kafka's `getTopicDescriptionFromDescribeTopicsResponseTopic` reads
1375    /// the authorized operations only when the caller asked for them.
1376    #[test]
1377    fn describe_page_reads_authorized_operations_on_request() {
1378        for (include, expected) in [
1379            (false, None),
1380            (
1381                true,
1382                Some(maplit::btreeset! {
1383                    AclOperation::Read,
1384                }),
1385            ),
1386        ] {
1387            let description = description_from_describe(
1388                &page_topic("alpha", 0, &[]),
1389                "alpha".to_owned(),
1390                &BTreeMap::new(),
1391                include,
1392            );
1393            assert2::assert!(
1394                description.authorized_operations == expected,
1395                "include {include}"
1396            );
1397        }
1398    }
1399}