Skip to main content

krabka_client_admin/
topics.rs

1//! Topic CRUD wrappers.
2
3use std::{
4    collections::{BTreeMap, HashMap, HashSet},
5    time::Duration,
6};
7
8#[cfg(test)]
9use krabka_protocol::owned::metadata_response;
10use krabka_protocol::{
11    owned::{
12        alter_partition_reassignments_request::{
13            AlterPartitionReassignmentsRequest, ReassignablePartition, ReassignableTopic,
14        },
15        alter_partition_reassignments_response::AlterPartitionReassignmentsResponse,
16        create_partitions_request::{
17            CreatePartitionsAssignment, CreatePartitionsRequest, CreatePartitionsTopic,
18        },
19        create_topics_request::{
20            CreatableReplicaAssignment, CreatableTopic, CreatableTopicConfig, CreateTopicsRequest,
21        },
22        delete_records_request::{
23            DeleteRecordsPartition, DeleteRecordsRequest, DeleteRecordsTopic,
24        },
25        delete_topics_request::{DeleteTopicState, DeleteTopicsRequest},
26        describe_cluster_request::DescribeClusterRequest,
27        list_partition_reassignments_request::{
28            ListPartitionReassignmentsRequest, ListPartitionReassignmentsTopics,
29        },
30        list_partition_reassignments_response::ListPartitionReassignmentsResponse,
31        metadata_request::{MetadataRequest, MetadataRequestTopic},
32        metadata_response::MetadataResponse,
33    },
34    primitives::uuid::Uuid as ProtoUuid,
35};
36use krabka_units::{Time, convert::TimeExt as _};
37use tokio::time::Instant;
38use uuid::Uuid;
39
40use crate::{
41    AdminClient, AdminError, KafkaError, NOT_CONTROLLER, kafka_error_if,
42    retry::{ControllerRetry, RetryPolicy, call_timeout_error, is_connection_failure},
43};
44
45/// One topic to create, as Kafka's `NewTopic`.
46#[derive(Debug, Clone, Default, bon::Builder)]
47pub struct CreateTopicSpec {
48    #[builder(into)]
49    pub name: String,
50    /// `-1` asks for the broker's `num.partitions`.
51    #[builder(default = -1)]
52    pub partitions: i32,
53    /// `-1` asks for the broker's `default.replication.factor`.
54    #[builder(default = -1)]
55    pub replicas: i32,
56    #[builder(default)]
57    pub configs: BTreeMap<String, String>,
58    /// The broker ids of each partition, by partition index, as
59    /// `NewTopic(name, replicasAssignments)` takes them. Kafka requires
60    /// `partitions` and `replicas` to be `-1` when this is set; an empty map
61    /// leaves placement to the broker.
62    #[builder(default)]
63    pub replica_assignments: BTreeMap<i32, Vec<i32>>,
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub struct CreateTopicOutcome {
68    pub name: String,
69    pub topic_id: Option<Uuid>,
70    pub error: Option<KafkaError>,
71    /// The throttle time of a `THROTTLING_QUOTA_EXCEEDED` (89) error, as
72    /// Kafka's `ThrottlingQuotaExceededException.throttleTimeMs` gives it.
73    /// `None` for every other outcome.
74    pub throttle_time: Option<Time>,
75}
76
77#[derive(Debug, Clone, PartialEq)]
78pub struct DeleteTopicOutcome {
79    pub name: String,
80    pub error: Option<KafkaError>,
81    /// The throttle time of a `THROTTLING_QUOTA_EXCEEDED` (89) error.
82    /// `None` for every other outcome.
83    pub throttle_time: Option<Time>,
84}
85
86/// Options of the topic mutation calls, as Kafka's `CreateTopicsOptions`,
87/// `DeleteTopicsOptions` and `CreatePartitionsOptions`.
88///
89/// They apply to [`AdminClientLike::create_topics`](crate::AdminClientLike::create_topics),
90/// [`AdminClientLike::delete_topics`](crate::AdminClientLike::delete_topics) and
91/// [`AdminClientLike::create_partitions`](crate::AdminClientLike::create_partitions).
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct TopicMutationOptions {
94    /// The deadline of the call, including retries. Each request carries the
95    /// time that remains as its `timeout_ms`. `None` uses Kafka's
96    /// `default.api.timeout.ms` (60 s).
97    pub timeout: Option<Time>,
98    /// Retry the topics that fail with `THROTTLING_QUOTA_EXCEEDED` (89) until
99    /// the deadline. Kafka's default is `true`.
100    pub retry_on_quota_violation: bool,
101}
102
103impl Default for TopicMutationOptions {
104    fn default() -> Self {
105        Self {
106            timeout: None,
107            retry_on_quota_violation: true,
108        }
109    }
110}
111
112impl TopicMutationOptions {
113    /// The default options with a call deadline of `timeout`.
114    #[must_use]
115    pub fn with_timeout(timeout: Time) -> Self {
116        Self {
117            timeout: Some(timeout),
118            ..Self::default()
119        }
120    }
121
122    /// The retry policy of the call: `base` with the call timeout, when set.
123    fn retry_policy(self, base: RetryPolicy) -> RetryPolicy {
124        self.timeout.map_or(base, |timeout| RetryPolicy {
125            timeout: Duration::from_millis(u64::try_from(timeout.millis_i64()).unwrap_or(0)),
126            ..base
127        })
128    }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct DeleteRecordsOp {
133    pub topic: String,
134    pub partition: i32,
135    pub offset: i64,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct DeleteRecordsOutcome {
140    pub topic: String,
141    pub partition: i32,
142    pub error_code: i16,
143    pub low_watermark: i64,
144}
145
146/// One topic to grow, as Kafka's `NewPartitions`.
147#[derive(Debug, Clone, Default)]
148pub struct CreatePartitionsOp {
149    pub name: String,
150    pub new_total_count: i32,
151    /// The broker ids of each new partition, in order, as
152    /// `NewPartitions.increaseTo(totalCount, newAssignments)` takes them.
153    /// `None` leaves placement to the broker.
154    pub assignments: Option<Vec<Vec<i32>>>,
155}
156
157#[derive(Debug, Clone, PartialEq)]
158pub struct CreatePartitionsOutcome {
159    pub name: String,
160    pub error: Option<KafkaError>,
161    /// The throttle time of a `THROTTLING_QUOTA_EXCEEDED` (89) error.
162    /// `None` for every other outcome.
163    pub throttle_time: Option<Time>,
164}
165
166#[derive(Debug, Clone, Default, PartialEq, Eq)]
167pub struct TopicMetadata {
168    pub controller_id: i32,
169    pub topics: Vec<TopicMetadataEntry>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct TopicMetadataEntry {
174    pub name: String,
175    pub topic_id: Option<Uuid>,
176    pub partition_count: i32,
177    pub replication_factor: i32,
178    pub error: Option<KafkaError>,
179}
180
181/// Result of converging one topic's partition assignments to a replication
182/// factor.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum TopicReplicationStatus {
185    /// Every partition already has the requested number of replicas.
186    InSync,
187    /// Kafka is still completing an earlier reassignment for the topic.
188    ReassignmentInProgress,
189    /// A reassignment for every out-of-sync partition was accepted.
190    ReassignmentSubmitted,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct PartitionAssignment {
195    pub topic: String,
196    pub partition: i32,
197    pub replicas: Vec<i32>,
198    pub adding_replicas: Vec<i32>,
199    pub removing_replicas: Vec<i32>,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct PartitionAssignmentOutcome {
204    pub topic: String,
205    pub partition: i32,
206    pub error: Option<KafkaError>,
207}
208
209impl AdminClient {
210    /// The call behind [`AdminClientLike::describe_partition_assignments`](crate::AdminClientLike::describe_partition_assignments).
211    pub(crate) async fn describe_partition_assignments_impl(
212        &self,
213        topics: &[&str],
214    ) -> Result<Vec<PartitionAssignment>, AdminError> {
215        let response: MetadataResponse = self.conn.send(build_metadata(topics)).await?;
216        Ok(response
217            .topics
218            .into_iter()
219            .flat_map(|topic| {
220                let name = topic.name.unwrap_or_default();
221                topic
222                    .partitions
223                    .into_iter()
224                    .map(move |partition| PartitionAssignment {
225                        topic: name.clone(),
226                        partition: partition.partition_index,
227                        replicas: partition.replica_nodes,
228                        adding_replicas: Vec::new(),
229                        removing_replicas: Vec::new(),
230                    })
231            })
232            .collect())
233    }
234
235    /// The call behind [`AdminClientLike::list_partition_reassignments`](crate::AdminClientLike::list_partition_reassignments).
236    pub(crate) async fn list_partition_reassignments_impl(
237        &self,
238        partitions: &BTreeMap<String, Vec<i32>>,
239        timeout: Time,
240    ) -> Result<Vec<PartitionAssignment>, AdminError> {
241        let response = self
242            .conn
243            .send(ListPartitionReassignmentsRequest {
244                timeout_ms: timeout.millis_i32(),
245                topics: (!partitions.is_empty()).then(|| {
246                    partitions
247                        .iter()
248                        .map(|(name, indexes)| ListPartitionReassignmentsTopics {
249                            name: name.clone(),
250                            partition_indexes: indexes.clone(),
251                            ..Default::default()
252                        })
253                        .collect()
254                }),
255                ..Default::default()
256            })
257            .await?;
258        broker_error(
259            "ListPartitionReassignments",
260            response.error_code,
261            response.error_message,
262        )?;
263        Ok(response
264            .topics
265            .into_iter()
266            .flat_map(|topic| {
267                topic
268                    .partitions
269                    .into_iter()
270                    .map(move |partition| PartitionAssignment {
271                        topic: topic.name.clone(),
272                        partition: partition.partition_index,
273                        replicas: partition.replicas,
274                        adding_replicas: partition.adding_replicas,
275                        removing_replicas: partition.removing_replicas,
276                    })
277            })
278            .collect())
279    }
280
281    /// The call behind [`AdminClientLike::alter_partition_assignments`](crate::AdminClientLike::alter_partition_assignments).
282    pub(crate) async fn alter_partition_assignments_impl(
283        &mut self,
284        assignments: &BTreeMap<(String, i32), Option<Vec<i32>>>,
285        timeout: Time,
286    ) -> Result<Vec<PartitionAssignmentOutcome>, AdminError> {
287        self.alter_partition_assignments_with(
288            assignments,
289            timeout,
290            AlterPartitionReassignmentsOptions::default(),
291        )
292        .await
293    }
294
295    /// Submits exact replica lists with Kafka's
296    /// `AlterPartitionReassignmentsOptions`.
297    ///
298    /// # Errors
299    /// Returns [`krabka_client_core::ClientError::IncompatibleVersion`] before sending when
300    /// `allow_replication_factor_change` is false and the controller has no
301    /// v1, as Kafka's `AlterPartitionReassignmentsRequest.Builder` refuses to
302    /// build it. Otherwise a transport, protocol, or top-level broker error.
303    pub async fn alter_partition_assignments_with(
304        &mut self,
305        assignments: &BTreeMap<(String, i32), Option<Vec<i32>>>,
306        timeout: Time,
307        options: AlterPartitionReassignmentsOptions,
308    ) -> Result<Vec<PartitionAssignmentOutcome>, AdminError> {
309        let request = build_partition_assignment_request(assignments, timeout, options);
310        let min_version = if options.allow_replication_factor_change {
311            0
312        } else {
313            ALLOW_REPLICATION_FACTOR_CHANGE_MIN_VERSION
314        };
315        let mut retry = ControllerRetry::new("AlterPartitionReassignments", self.retry);
316        loop {
317            let response = retry
318                .bounded(self.conn.send_at_least(request.clone(), min_version))
319                .await?;
320            if response.error_code != NOT_CONTROLLER {
321                return parse_partition_assignment_response(response);
322            }
323            retry.after_not_controller(self).await?;
324        }
325    }
326
327    /// The call behind [`AdminClientLike::metadata`](crate::AdminClientLike::metadata).
328    pub(crate) async fn metadata_impl(&self, topics: &[&str]) -> Result<TopicMetadata, AdminError> {
329        let req = build_metadata(topics);
330        let resp = self.conn.send(req).await?;
331        Ok(parse_metadata(resp))
332    }
333
334    /// The call behind [`AdminClientLike::reconcile_topic_replication_factor`](crate::AdminClientLike::reconcile_topic_replication_factor).
335    pub(crate) async fn reconcile_topic_replication_factor_impl(
336        &self,
337        topic: &str,
338        replication_factor: i32,
339        timeout: Time,
340    ) -> Result<TopicReplicationStatus, AdminError> {
341        if self.conn.uses_controller_bootstrap() {
342            return Err(AdminError::Broker {
343                api: "ControllerEndpoint",
344                code: 115,
345                name: "UNSUPPORTED_ENDPOINT_TYPE",
346                message: Some(
347                    "replication-factor reconciliation requires a broker bootstrap endpoint".into(),
348                ),
349            });
350        }
351        // Replica selection depends on the controller's authoritative
352        // heartbeat registry. Connect to the active controller before reading
353        // Metadata so dead-but-still-registered brokers are not candidates.
354        // The call deadline starts before the first controller refresh, so the
355        // refresh and the retries share one `default.api.timeout.ms`.
356        let mut retry = ControllerRetry::new("AlterPartitionReassignments", self.retry);
357        retry.bounded(self.refresh_controller_connection()).await?;
358        loop {
359            match retry
360                .bounded(self.reconcile_topic_replication_factor_once(
361                    topic,
362                    replication_factor,
363                    timeout,
364                ))
365                .await
366            {
367                Err(AdminError::Broker {
368                    code: NOT_CONTROLLER,
369                    ..
370                }) => retry.after_not_controller(self).await?,
371                result => return result,
372            }
373        }
374    }
375
376    async fn reconcile_topic_replication_factor_once(
377        &self,
378        topic: &str,
379        replication_factor: i32,
380        timeout: Time,
381    ) -> Result<TopicReplicationStatus, AdminError> {
382        let ongoing: ListPartitionReassignmentsResponse = self
383            .conn
384            .send(ListPartitionReassignmentsRequest::default())
385            .await?;
386        broker_error(
387            "ListPartitionReassignments",
388            ongoing.error_code,
389            ongoing.error_message,
390        )?;
391        if ongoing.topics.iter().any(|entry| entry.name == topic) {
392            return Ok(TopicReplicationStatus::ReassignmentInProgress);
393        }
394
395        // The public entrypoint reconnects to the active controller before
396        // calling this method, so DescribeCluster carries that controller's
397        // authoritative heartbeat/fencing state.
398        let cluster = self
399            .conn
400            .send_at_least(
401                DescribeClusterRequest {
402                    include_fenced_brokers: true,
403                    ..Default::default()
404                },
405                2,
406            )
407            .await?;
408        broker_error("DescribeCluster", cluster.error_code, cluster.error_message)?;
409        let eligible_brokers = cluster
410            .brokers
411            .iter()
412            .filter(|broker| !broker.is_fenced)
413            .map(|broker| broker.broker_id)
414            .collect::<Vec<_>>();
415
416        let metadata: MetadataResponse = self.conn.send(build_metadata(&[topic])).await?;
417        let Some(request) = build_replication_factor_reassignment(
418            &metadata,
419            &eligible_brokers,
420            topic,
421            replication_factor,
422            timeout,
423        )?
424        else {
425            return Ok(TopicReplicationStatus::InSync);
426        };
427        let response: AlterPartitionReassignmentsResponse = self.conn.send(request).await?;
428        broker_error(
429            "AlterPartitionReassignments",
430            response.error_code,
431            response.error_message,
432        )?;
433        if let Some(error) = response.responses.into_iter().find_map(|topic_response| {
434            topic_response
435                .partitions
436                .into_iter()
437                .find(|partition| partition.error_code != 0)
438        }) {
439            return Err(AdminError::Broker {
440                api: "AlterPartitionReassignments",
441                code: error.error_code,
442                name: crate::kafka_error_name(error.error_code),
443                message: error.error_message,
444            });
445        }
446        Ok(TopicReplicationStatus::ReassignmentSubmitted)
447    }
448
449    /// The call behind [`AdminClientLike::create_topics`](crate::AdminClientLike::create_topics).
450    pub(crate) async fn create_topics_impl(
451        &self,
452        specs: &[CreateTopicSpec],
453        options: TopicMutationOptions,
454    ) -> Result<Vec<CreateTopicOutcome>, AdminError> {
455        self.create_topics_with_retry(specs, options, options.retry_policy(self.retry))
456            .await
457    }
458
459    async fn create_topics_with_retry(
460        &self,
461        specs: &[CreateTopicSpec],
462        options: TopicMutationOptions,
463        policy: RetryPolicy,
464    ) -> Result<Vec<CreateTopicOutcome>, AdminError> {
465        let names = specs.iter().map(|spec| spec.name.clone()).collect();
466        self.mutate_topics(
467            "CreateTopics",
468            names,
469            options.retry_on_quota_violation,
470            policy,
471            |pending, timeout_ms| {
472                let specs = specs
473                    .iter()
474                    .filter(|spec| pending.contains(&spec.name))
475                    .cloned()
476                    .collect::<Vec<_>>();
477                build_create_topics(&specs, timeout_ms)
478            },
479            parse_create_topics,
480        )
481        .await
482    }
483
484    /// The call behind [`AdminClientLike::delete_topics`](crate::AdminClientLike::delete_topics).
485    pub(crate) async fn delete_topics_impl(
486        &self,
487        names: &[&str],
488        options: TopicMutationOptions,
489    ) -> Result<Vec<DeleteTopicOutcome>, AdminError> {
490        self.delete_topics_with_retry(names, options, options.retry_policy(self.retry))
491            .await
492    }
493
494    async fn delete_topics_with_retry(
495        &self,
496        names: &[&str],
497        options: TopicMutationOptions,
498        policy: RetryPolicy,
499    ) -> Result<Vec<DeleteTopicOutcome>, AdminError> {
500        let names = names.iter().map(|name| (*name).to_owned()).collect();
501        self.mutate_topics(
502            "DeleteTopics",
503            names,
504            options.retry_on_quota_violation,
505            policy,
506            build_delete_topics,
507            parse_delete_topics,
508        )
509        .await
510    }
511
512    /// The call behind [`AdminClientLike::create_partitions`](crate::AdminClientLike::create_partitions).
513    pub(crate) async fn create_partitions_impl(
514        &self,
515        ops: &[CreatePartitionsOp],
516        options: TopicMutationOptions,
517    ) -> Result<Vec<CreatePartitionsOutcome>, AdminError> {
518        self.create_partitions_with_retry(ops, options, options.retry_policy(self.retry))
519            .await
520    }
521
522    async fn create_partitions_with_retry(
523        &self,
524        ops: &[CreatePartitionsOp],
525        options: TopicMutationOptions,
526        policy: RetryPolicy,
527    ) -> Result<Vec<CreatePartitionsOutcome>, AdminError> {
528        let names = ops.iter().map(|op| op.name.clone()).collect();
529        self.mutate_topics(
530            "CreatePartitions",
531            names,
532            options.retry_on_quota_violation,
533            policy,
534            |pending, timeout_ms| {
535                let ops = ops
536                    .iter()
537                    .filter(|op| pending.contains(&op.name))
538                    .cloned()
539                    .collect::<Vec<_>>();
540                build_create_partitions(&ops, timeout_ms)
541            },
542            parse_create_partitions,
543        )
544        .await
545    }
546
547    /// Sends one topic mutation until every topic has an outcome or the
548    /// deadline passes. See [`AdminClientLike::create_topics`](crate::AdminClientLike::create_topics) for the rules.
549    async fn mutate_topics<R, T>(
550        &self,
551        api: &'static str,
552        topics: Vec<String>,
553        retry_on_quota_violation: bool,
554        policy: RetryPolicy,
555        build: impl Fn(&[String], i32) -> R,
556        parse: impl Fn(R::Response) -> Vec<T>,
557    ) -> Result<Vec<T>, AdminError>
558    where
559        R: krabka_protocol::ProtocolRequest + Clone,
560        R::Response: 'static,
561        T: TopicMutationOutcome,
562    {
563        let mut order = Vec::new();
564        for topic in topics {
565            if !order.contains(&topic) {
566                order.push(topic);
567            }
568        }
569        let mut pending = order.clone();
570        let mut done = HashMap::<String, T>::new();
571        let mut throttled = HashMap::<String, (T, Instant)>::new();
572        let mut deadline = policy.start();
573        let mut attempts = 0_u32;
574        let mut last_error = "none".to_owned();
575        while !pending.is_empty() {
576            attempts = attempts.saturating_add(1);
577            let request = build(&pending, deadline.remaining_millis());
578            // A request still in flight at the deadline stops the call, as
579            // Kafka's `KafkaAdminClient` times out a call in flight.
580            let Some(response) = deadline.bounded(self.conn.send(request)).await else {
581                "the request was in flight at the deadline".clone_into(&mut last_error);
582                break;
583            };
584            let outcomes = match response {
585                Ok(response) => parse(response),
586                // A lost or failed connection is a disconnect, which Kafka's
587                // `Call.fail` retries with the backoff until the deadline.
588                Err(error) if is_connection_failure(&error) => {
589                    last_error = error.to_string();
590                    if deadline.exhausted() {
591                        break;
592                    }
593                    deadline.backoff().await;
594                    if deadline.expired() {
595                        break;
596                    }
597                    continue;
598                }
599                Err(error) => return Err(error),
600            };
601            let controller_bootstrap = self.conn.uses_controller_bootstrap();
602            if outcomes
603                .iter()
604                .any(|outcome| is_not_controller(outcome.error_code(), controller_bootstrap))
605            {
606                "NOT_CONTROLLER".clone_into(&mut last_error);
607                if deadline
608                    .bounded(self.refresh_controller_after_not_controller())
609                    .await
610                    .transpose()?
611                    .is_none()
612                {
613                    break;
614                }
615                if deadline.exhausted() {
616                    break;
617                }
618                deadline.backoff().await;
619                if deadline.expired() {
620                    break;
621                }
622                continue;
623            }
624
625            let received = Instant::now();
626            let mut retry = Vec::new();
627            let mut throttle = Duration::ZERO;
628            for outcome in outcomes {
629                let topic = outcome.topic().to_owned();
630                if !pending.contains(&topic) || done.contains_key(&topic) {
631                    tracing::warn!(api, topic, "server response mentioned an unknown topic");
632                    continue;
633                }
634                if retry_on_quota_violation && outcome.error_code() == THROTTLING_QUOTA_EXCEEDED {
635                    throttle = throttle.max(outcome.throttle_duration());
636                    retry.push(topic.clone());
637                    throttled.insert(topic, (outcome, received));
638                } else {
639                    throttled.remove(&topic);
640                    done.insert(topic, outcome);
641                }
642            }
643            for topic in &pending {
644                if !done.contains_key(topic) && !retry.contains(topic) {
645                    let error = KafkaError {
646                        code: UNKNOWN_SERVER_ERROR,
647                        name: crate::kafka_error_name(UNKNOWN_SERVER_ERROR),
648                        message: Some(format!(
649                            "The controller response did not contain a result for topic {topic}"
650                        )),
651                    };
652                    done.insert(topic.clone(), T::failed(topic, error));
653                }
654            }
655            pending = retry;
656            if pending.is_empty() {
657                break;
658            }
659            "THROTTLING_QUOTA_EXCEEDED".clone_into(&mut last_error);
660            // Kafka's `NetworkClient` mutes the connection for the throttle
661            // time of the response (KIP-219) before the retry goes out. A
662            // zero throttle time falls back to the retry backoff, so a broken
663            // broker cannot make the call spin.
664            if throttle.is_zero() {
665                deadline.backoff().await;
666            } else {
667                deadline.sleep(throttle).await;
668            }
669            if deadline.expired() {
670                break;
671            }
672        }
673        for topic in pending {
674            let outcome = match throttled.remove(&topic) {
675                Some((outcome, received)) => {
676                    let remaining = outcome
677                        .throttle_duration()
678                        .saturating_sub(received.elapsed());
679                    outcome.with_throttle_duration(remaining)
680                }
681                None => T::failed(&topic, call_timeout_error(api, attempts, &last_error)),
682            };
683            done.insert(topic, outcome);
684        }
685        Ok(order
686            .into_iter()
687            .filter_map(|topic| done.remove(&topic))
688            .collect())
689    }
690
691    /// The call behind [`AdminClientLike::delete_records`](crate::AdminClientLike::delete_records).
692    pub(crate) async fn delete_records_impl(
693        &self,
694        ops: &[DeleteRecordsOp],
695        timeout: Time,
696    ) -> Result<Vec<DeleteRecordsOutcome>, AdminError> {
697        if ops.is_empty() {
698            return Ok(Vec::new());
699        }
700
701        let mut outcomes = Vec::new();
702        let groups = self
703            .delete_records_leader_groups(ops, &mut outcomes)
704            .await?;
705        for (endpoint, leader_ops) in groups {
706            self.reconnect(&endpoint).await?;
707            let req = build_delete_records(&leader_ops, timeout);
708            let resp = self.conn.send(req).await?;
709            outcomes.extend(parse_delete_records(resp));
710        }
711        Ok(outcomes)
712    }
713
714    /// Fetches Metadata, finds the controller's `host:port`, and replaces
715    /// `self.conn` with a connection to it. The per-method `NOT_CONTROLLER`
716    /// retry paths above use it.
717    pub(crate) async fn refresh_controller_connection(&self) -> Result<(), AdminError> {
718        if self.conn.uses_controller_bootstrap() {
719            return self.conn.rebootstrap().await;
720        }
721        let md_resp = self.conn.send(build_metadata(&[])).await?;
722        let Some(controller_addr) = controller_endpoint(&md_resp) else {
723            // In-process/test brokers can advertise port 0 while the
724            // bootstrap address still contains the actual bound port. Reuse
725            // that known-good address instead of attempting `host:0`.
726            if controller_requires_bootstrap_fallback(&md_resp) {
727                return self.conn.rebootstrap().await;
728            }
729            return Err(AdminError::Protocol(format!(
730                "metadata names no endpoint for controller {}",
731                md_resp.controller_id
732            )));
733        };
734        self.reconnect(&controller_addr).await
735    }
736
737    async fn delete_records_leader_groups(
738        &self,
739        ops: &[DeleteRecordsOp],
740        outcomes: &mut Vec<DeleteRecordsOutcome>,
741    ) -> Result<BTreeMap<String, Vec<DeleteRecordsOp>>, AdminError> {
742        let mut topic_names = ops.iter().map(|op| op.topic.as_str()).collect::<Vec<_>>();
743        topic_names.sort_unstable();
744        topic_names.dedup();
745
746        let metadata = self.conn.send(build_metadata(&topic_names)).await?;
747        let broker_endpoints = metadata
748            .brokers
749            .iter()
750            .map(|broker| {
751                let endpoint = if broker.port > 0 {
752                    format!("{}:{}", broker.host, broker.port)
753                } else {
754                    self.bootstrap_addrs
755                        .first()
756                        .cloned()
757                        .unwrap_or_else(|| format!("{}:{}", broker.host, broker.port))
758                };
759                (broker.node_id, endpoint)
760            })
761            .collect::<BTreeMap<_, _>>();
762        let mut groups = BTreeMap::<String, Vec<DeleteRecordsOp>>::new();
763
764        for op in ops {
765            let Some(topic) = metadata
766                .topics
767                .iter()
768                .find(|topic| topic.name.as_deref() == Some(op.topic.as_str()))
769            else {
770                outcomes.push(delete_records_error_outcome(op, 3));
771                continue;
772            };
773
774            if topic.error_code != 0 {
775                outcomes.push(delete_records_error_outcome(op, topic.error_code));
776                continue;
777            }
778
779            let Some(partition) = topic
780                .partitions
781                .iter()
782                .find(|partition| partition.partition_index == op.partition)
783            else {
784                outcomes.push(delete_records_error_outcome(op, 3));
785                continue;
786            };
787
788            if partition.error_code != 0 {
789                outcomes.push(delete_records_error_outcome(op, partition.error_code));
790                continue;
791            }
792
793            let Some(endpoint) = broker_endpoints.get(&partition.leader_id) else {
794                return Err(AdminError::Protocol(format!(
795                    "no broker endpoint for DeleteRecords leader {}",
796                    partition.leader_id
797                )));
798            };
799            groups.entry(endpoint.clone()).or_default().push(op.clone());
800        }
801
802        Ok(groups)
803    }
804}
805
806/// `UNKNOWN_SERVER_ERROR`.
807const UNKNOWN_SERVER_ERROR: i16 = -1;
808/// `NOT_LEADER_OR_FOLLOWER`: a controller that is not the active controller
809/// answers with it through controller bootstrap.
810const NOT_LEADER_OR_FOLLOWER: i16 = 6;
811/// `THROTTLING_QUOTA_EXCEEDED`: the controller mutation quota (KIP-599).
812const THROTTLING_QUOTA_EXCEEDED: i16 = 89;
813
814/// Whether a topic code makes Kafka's `handleNotControllerError` find the
815/// controller again. `NOT_LEADER_OR_FOLLOWER` counts only with controller
816/// bootstrap.
817fn is_not_controller(code: i16, controller_bootstrap: bool) -> bool {
818    code == NOT_CONTROLLER || (controller_bootstrap && code == NOT_LEADER_OR_FOLLOWER)
819}
820
821/// One per-topic outcome of a topic mutation.
822trait TopicMutationOutcome {
823    /// The topic name.
824    fn topic(&self) -> &str;
825    /// The error code, 0 for success.
826    fn error_code(&self) -> i16;
827    /// The throttle time of the outcome, zero when it has none.
828    fn throttle_duration(&self) -> Duration;
829    /// This outcome with the throttle time `remaining`.
830    #[must_use]
831    fn with_throttle_duration(self, remaining: Duration) -> Self;
832    /// A failed outcome for `topic`.
833    fn failed(topic: &str, error: KafkaError) -> Self;
834}
835
836fn throttle_duration(throttle_time: Option<Time>) -> Duration {
837    throttle_time.map_or(Duration::ZERO, |time| {
838        Duration::from_millis(u64::try_from(time.millis_i64()).unwrap_or(0))
839    })
840}
841
842fn throttle_time(remaining: Duration) -> Time {
843    Time::from_millis(i64::try_from(remaining.as_millis()).unwrap_or(i64::MAX))
844}
845
846/// The throttle time of an outcome with `code`: the response throttle time
847/// for `THROTTLING_QUOTA_EXCEEDED`, `None` otherwise.
848fn quota_throttle_time(code: i16, throttle_time_ms: i32) -> Option<Time> {
849    (code == THROTTLING_QUOTA_EXCEEDED).then(|| Time::from_millis(i64::from(throttle_time_ms)))
850}
851
852impl TopicMutationOutcome for CreateTopicOutcome {
853    fn topic(&self) -> &str {
854        &self.name
855    }
856    fn error_code(&self) -> i16 {
857        self.error.as_ref().map_or(0, |error| error.code)
858    }
859    fn throttle_duration(&self) -> Duration {
860        throttle_duration(self.throttle_time)
861    }
862    fn with_throttle_duration(self, remaining: Duration) -> Self {
863        Self {
864            throttle_time: Some(throttle_time(remaining)),
865            ..self
866        }
867    }
868    fn failed(topic: &str, error: KafkaError) -> Self {
869        Self {
870            name: topic.to_owned(),
871            topic_id: None,
872            error: Some(error),
873            throttle_time: None,
874        }
875    }
876}
877
878impl TopicMutationOutcome for DeleteTopicOutcome {
879    fn topic(&self) -> &str {
880        &self.name
881    }
882    fn error_code(&self) -> i16 {
883        self.error.as_ref().map_or(0, |error| error.code)
884    }
885    fn throttle_duration(&self) -> Duration {
886        throttle_duration(self.throttle_time)
887    }
888    fn with_throttle_duration(self, remaining: Duration) -> Self {
889        Self {
890            throttle_time: Some(throttle_time(remaining)),
891            ..self
892        }
893    }
894    fn failed(topic: &str, error: KafkaError) -> Self {
895        Self {
896            name: topic.to_owned(),
897            error: Some(error),
898            throttle_time: None,
899        }
900    }
901}
902
903impl TopicMutationOutcome for CreatePartitionsOutcome {
904    fn topic(&self) -> &str {
905        &self.name
906    }
907    fn error_code(&self) -> i16 {
908        self.error.as_ref().map_or(0, |error| error.code)
909    }
910    fn throttle_duration(&self) -> Duration {
911        throttle_duration(self.throttle_time)
912    }
913    fn with_throttle_duration(self, remaining: Duration) -> Self {
914        Self {
915            throttle_time: Some(throttle_time(remaining)),
916            ..self
917        }
918    }
919    fn failed(topic: &str, error: KafkaError) -> Self {
920        Self {
921            name: topic.to_owned(),
922            error: Some(error),
923            throttle_time: None,
924        }
925    }
926}
927
928/// Kafka's `AlterPartitionReassignmentsOptions`.
929#[derive(Debug, Clone, Copy, PartialEq, Eq)]
930pub struct AlterPartitionReassignmentsOptions {
931    /// Whether a reassignment may change a partition's replication factor.
932    /// Kafka's default is `true`; `false` needs request v1.
933    pub allow_replication_factor_change: bool,
934}
935
936impl Default for AlterPartitionReassignmentsOptions {
937    fn default() -> Self {
938        Self {
939            allow_replication_factor_change: true,
940        }
941    }
942}
943
944/// The first `AlterPartitionReassignments` version with
945/// `AllowReplicationFactorChange`.
946const ALLOW_REPLICATION_FACTOR_CHANGE_MIN_VERSION: i16 = 1;
947
948fn build_partition_assignment_request(
949    assignments: &BTreeMap<(String, i32), Option<Vec<i32>>>,
950    timeout: Time,
951    options: AlterPartitionReassignmentsOptions,
952) -> AlterPartitionReassignmentsRequest {
953    let mut topics = BTreeMap::<String, Vec<ReassignablePartition>>::new();
954    for ((topic, partition), replicas) in assignments {
955        topics
956            .entry(topic.clone())
957            .or_default()
958            .push(ReassignablePartition {
959                partition_index: *partition,
960                replicas: replicas.clone(),
961                ..Default::default()
962            });
963    }
964    AlterPartitionReassignmentsRequest {
965        timeout_ms: timeout.millis_i32(),
966        allow_replication_factor_change: options.allow_replication_factor_change,
967        topics: topics
968            .into_iter()
969            .map(|(name, partitions)| ReassignableTopic {
970                name,
971                partitions,
972                ..Default::default()
973            })
974            .collect(),
975        ..Default::default()
976    }
977}
978
979fn parse_partition_assignment_response(
980    response: AlterPartitionReassignmentsResponse,
981) -> Result<Vec<PartitionAssignmentOutcome>, AdminError> {
982    broker_error(
983        "AlterPartitionReassignments",
984        response.error_code,
985        response.error_message,
986    )?;
987    Ok(response
988        .responses
989        .into_iter()
990        .flat_map(|topic| {
991            topic
992                .partitions
993                .into_iter()
994                .map(move |partition| PartitionAssignmentOutcome {
995                    topic: topic.name.clone(),
996                    partition: partition.partition_index,
997                    error: kafka_error_if(partition.error_code, partition.error_message),
998                })
999        })
1000        .collect())
1001}
1002
1003fn build_metadata(topics: &[&str]) -> MetadataRequest {
1004    MetadataRequest {
1005        topics: if topics.is_empty() {
1006            None
1007        } else {
1008            Some(
1009                topics
1010                    .iter()
1011                    .map(|n| MetadataRequestTopic {
1012                        topic_id: ProtoUuid::ZERO,
1013                        name: Some((*n).to_owned()),
1014                        ..Default::default()
1015                    })
1016                    .collect(),
1017            )
1018        },
1019        allow_auto_topic_creation: false,
1020        include_cluster_authorized_operations: false,
1021        include_topic_authorized_operations: false,
1022        ..Default::default()
1023    }
1024}
1025
1026fn broker_error(api: &'static str, code: i16, message: Option<String>) -> Result<(), AdminError> {
1027    if code == 0 {
1028        return Ok(());
1029    }
1030    Err(AdminError::Broker {
1031        api,
1032        code,
1033        name: crate::kafka_error_name(code),
1034        message,
1035    })
1036}
1037
1038fn build_replication_factor_reassignment(
1039    metadata: &MetadataResponse,
1040    eligible_brokers: &[i32],
1041    topic_name: &str,
1042    replication_factor: i32,
1043    timeout: Time,
1044) -> Result<Option<AlterPartitionReassignmentsRequest>, AdminError> {
1045    broker_error("Metadata", metadata.error_code, None)?;
1046    let desired = usize::try_from(replication_factor).map_err(|_| {
1047        AdminError::Protocol("replication factor must be a positive integer".into())
1048    })?;
1049    if desired == 0 {
1050        return Err(AdminError::Protocol(
1051            "replication factor must be a positive integer".into(),
1052        ));
1053    }
1054
1055    let mut brokers = eligible_brokers.to_vec();
1056    brokers.sort_unstable();
1057    brokers.dedup();
1058    if desired > brokers.len() {
1059        return Err(AdminError::Broker {
1060            api: "AlterPartitionReassignments",
1061            code: 38,
1062            name: crate::kafka_error_name(38),
1063            message: Some(format!(
1064                "replication factor {replication_factor} exceeds live broker count {}",
1065                brokers.len()
1066            )),
1067        });
1068    }
1069
1070    let topic = metadata
1071        .topics
1072        .iter()
1073        .find(|topic| topic.name.as_deref() == Some(topic_name))
1074        .ok_or_else(|| AdminError::Broker {
1075            api: "Metadata",
1076            code: 3,
1077            name: crate::kafka_error_name(3),
1078            message: Some(format!("topic {topic_name:?} is absent from metadata")),
1079        })?;
1080    broker_error("Metadata", topic.error_code, None)?;
1081
1082    let available = brokers.iter().copied().collect::<HashSet<_>>();
1083    let mut partitions = Vec::new();
1084    for (rotation, partition) in topic.partitions.iter().enumerate() {
1085        broker_error("Metadata", partition.error_code, None)?;
1086        let mut selected = HashSet::with_capacity(desired);
1087        let mut target = partition
1088            .replica_nodes
1089            .iter()
1090            .copied()
1091            .filter(|broker| available.contains(broker) && selected.insert(*broker))
1092            .take(desired)
1093            .collect::<Vec<_>>();
1094        let start = rotation.checked_rem(brokers.len()).unwrap_or(0);
1095        for &broker in brokers.iter().cycle().skip(start).take(brokers.len()) {
1096            if target.len() == desired {
1097                break;
1098            }
1099            if selected.insert(broker) {
1100                target.push(broker);
1101            }
1102        }
1103        if target != partition.replica_nodes {
1104            partitions.push(ReassignablePartition {
1105                partition_index: partition.partition_index,
1106                replicas: Some(target),
1107                ..Default::default()
1108            });
1109        }
1110    }
1111    if partitions.is_empty() {
1112        return Ok(None);
1113    }
1114
1115    Ok(Some(AlterPartitionReassignmentsRequest {
1116        timeout_ms: timeout.millis_i32(),
1117        allow_replication_factor_change: true,
1118        topics: vec![ReassignableTopic {
1119            name: topic_name.to_owned(),
1120            partitions,
1121            ..Default::default()
1122        }],
1123        ..Default::default()
1124    }))
1125}
1126
1127fn build_create_topics(specs: &[CreateTopicSpec], timeout_ms: i32) -> CreateTopicsRequest {
1128    CreateTopicsRequest {
1129        topics: specs
1130            .iter()
1131            .map(|s| CreatableTopic {
1132                name: s.name.clone(),
1133                num_partitions: s.partitions,
1134                replication_factor: i16::try_from(s.replicas).unwrap_or(i16::MAX),
1135                assignments: s
1136                    .replica_assignments
1137                    .iter()
1138                    .map(|(partition_index, broker_ids)| CreatableReplicaAssignment {
1139                        partition_index: *partition_index,
1140                        broker_ids: broker_ids.clone(),
1141                        ..Default::default()
1142                    })
1143                    .collect(),
1144                configs: s
1145                    .configs
1146                    .iter()
1147                    .map(|(k, v)| CreatableTopicConfig {
1148                        name: k.clone(),
1149                        value: Some(v.clone()),
1150                        ..Default::default()
1151                    })
1152                    .collect(),
1153                ..Default::default()
1154            })
1155            .collect(),
1156        timeout_ms,
1157        validate_only: false,
1158        ..Default::default()
1159    }
1160}
1161
1162/// A `DeleteTopicsRequest` that names `names`. It fills both the v0-v5
1163/// `topic_names` field and the v6+ `topics` field, and the encoder uses the
1164/// field of the negotiated version.
1165fn build_delete_topics(names: &[String], timeout_ms: i32) -> DeleteTopicsRequest {
1166    DeleteTopicsRequest {
1167        topic_names: names.to_vec(),
1168        topics: names
1169            .iter()
1170            .map(|name| DeleteTopicState {
1171                name: Some(name.clone()),
1172                topic_id: ProtoUuid::ZERO,
1173                ..Default::default()
1174            })
1175            .collect(),
1176        timeout_ms,
1177        ..Default::default()
1178    }
1179}
1180
1181fn build_create_partitions(ops: &[CreatePartitionsOp], timeout_ms: i32) -> CreatePartitionsRequest {
1182    CreatePartitionsRequest {
1183        topics: ops
1184            .iter()
1185            .map(|op| CreatePartitionsTopic {
1186                name: op.name.clone(),
1187                count: op.new_total_count,
1188                assignments: op.assignments.as_ref().map(|assignments| {
1189                    assignments
1190                        .iter()
1191                        .map(|broker_ids| CreatePartitionsAssignment {
1192                            broker_ids: broker_ids.clone(),
1193                            ..Default::default()
1194                        })
1195                        .collect()
1196                }),
1197                ..Default::default()
1198            })
1199            .collect(),
1200        timeout_ms,
1201        validate_only: false,
1202        ..Default::default()
1203    }
1204}
1205
1206fn build_delete_records(ops: &[DeleteRecordsOp], timeout: Time) -> DeleteRecordsRequest {
1207    let mut topics = BTreeMap::<String, Vec<DeleteRecordsPartition>>::new();
1208    for op in ops {
1209        topics
1210            .entry(op.topic.clone())
1211            .or_default()
1212            .push(DeleteRecordsPartition {
1213                partition_index: op.partition,
1214                offset: op.offset,
1215                ..Default::default()
1216            });
1217    }
1218
1219    DeleteRecordsRequest {
1220        topics: topics
1221            .into_iter()
1222            .map(|(name, partitions)| DeleteRecordsTopic {
1223                name,
1224                partitions,
1225                ..Default::default()
1226            })
1227            .collect(),
1228        timeout_ms: timeout.millis_i32(),
1229        ..Default::default()
1230    }
1231}
1232
1233fn parse_create_topics(
1234    resp: <CreateTopicsRequest as krabka_protocol::ProtocolRequest>::Response,
1235) -> Vec<CreateTopicOutcome> {
1236    let throttle_time_ms = resp.throttle_time_ms;
1237    resp.topics
1238        .into_iter()
1239        .map(|t| CreateTopicOutcome {
1240            name: t.name,
1241            topic_id: proto_uuid_to_opt(t.topic_id),
1242            throttle_time: quota_throttle_time(t.error_code, throttle_time_ms),
1243            error: kafka_error_if(t.error_code, t.error_message),
1244        })
1245        .collect()
1246}
1247
1248fn parse_delete_topics(
1249    resp: <DeleteTopicsRequest as krabka_protocol::ProtocolRequest>::Response,
1250) -> Vec<DeleteTopicOutcome> {
1251    let throttle_time_ms = resp.throttle_time_ms;
1252    resp.responses
1253        .into_iter()
1254        .map(|t| DeleteTopicOutcome {
1255            name: t.name.unwrap_or_default(),
1256            throttle_time: quota_throttle_time(t.error_code, throttle_time_ms),
1257            error: kafka_error_if(t.error_code, t.error_message),
1258        })
1259        .collect()
1260}
1261
1262fn parse_create_partitions(
1263    resp: <CreatePartitionsRequest as krabka_protocol::ProtocolRequest>::Response,
1264) -> Vec<CreatePartitionsOutcome> {
1265    let throttle_time_ms = resp.throttle_time_ms;
1266    resp.results
1267        .into_iter()
1268        .map(|t| CreatePartitionsOutcome {
1269            name: t.name,
1270            throttle_time: quota_throttle_time(t.error_code, throttle_time_ms),
1271            error: kafka_error_if(t.error_code, t.error_message),
1272        })
1273        .collect()
1274}
1275
1276fn parse_delete_records(
1277    resp: <DeleteRecordsRequest as krabka_protocol::ProtocolRequest>::Response,
1278) -> Vec<DeleteRecordsOutcome> {
1279    resp.topics
1280        .into_iter()
1281        .flat_map(|topic| {
1282            let topic_name = topic.name;
1283            topic
1284                .partitions
1285                .into_iter()
1286                .map(move |partition| DeleteRecordsOutcome {
1287                    topic: topic_name.clone(),
1288                    partition: partition.partition_index,
1289                    error_code: partition.error_code,
1290                    low_watermark: partition.low_watermark,
1291                })
1292        })
1293        .collect()
1294}
1295
1296fn delete_records_error_outcome(op: &DeleteRecordsOp, error_code: i16) -> DeleteRecordsOutcome {
1297    DeleteRecordsOutcome {
1298        topic: op.topic.clone(),
1299        partition: op.partition,
1300        error_code,
1301        low_watermark: -1,
1302    }
1303}
1304
1305fn parse_metadata(
1306    resp: <MetadataRequest as krabka_protocol::ProtocolRequest>::Response,
1307) -> TopicMetadata {
1308    let topics = resp
1309        .topics
1310        .into_iter()
1311        .map(|t| {
1312            let partition_count = i32::try_from(t.partitions.len()).unwrap_or(i32::MAX);
1313            let replication_factor = i32::from(t.partitions.first().map_or(0, |p| {
1314                i16::try_from(p.replica_nodes.len()).unwrap_or(i16::MAX)
1315            }));
1316            TopicMetadataEntry {
1317                name: t.name.unwrap_or_default(),
1318                topic_id: proto_uuid_to_opt(t.topic_id),
1319                partition_count,
1320                replication_factor,
1321                error: kafka_error_if(t.error_code, None),
1322            }
1323        })
1324        .collect();
1325    TopicMetadata {
1326        controller_id: resp.controller_id,
1327        topics,
1328    }
1329}
1330
1331fn controller_endpoint(
1332    resp: &<MetadataRequest as krabka_protocol::ProtocolRequest>::Response,
1333) -> Option<String> {
1334    let id = resp.controller_id;
1335    resp.brokers
1336        .iter()
1337        .find(|b| b.node_id == id && !b.host.is_empty() && b.port > 0)
1338        .map(|b| format!("{}:{}", b.host, b.port))
1339}
1340
1341fn controller_requires_bootstrap_fallback(
1342    resp: &<MetadataRequest as krabka_protocol::ProtocolRequest>::Response,
1343) -> bool {
1344    resp.brokers
1345        .iter()
1346        .any(|broker| broker.node_id == resp.controller_id && broker.port <= 0)
1347}
1348
1349fn proto_uuid_to_opt(u: ProtoUuid) -> Option<Uuid> {
1350    if u == ProtoUuid::ZERO {
1351        None
1352    } else {
1353        Some(Uuid::from_bytes(u.0))
1354    }
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359    use std::collections::BTreeMap;
1360
1361    use krabka_protocol::{
1362        UnknownTaggedFields,
1363        owned::metadata_response::{
1364            MetadataResponseBroker, MetadataResponsePartition, MetadataResponseTopic,
1365        },
1366    };
1367
1368    use super::*;
1369    use crate::{AdminClientLike as _, retry::KAFKA_ADMIN_RETRY};
1370
1371    #[test]
1372    fn exact_assignment_request_preserves_replica_order() {
1373        let assignments = maplit::btreemap! {
1374            ("orders".into(), 3) => Some(vec![4, 2, 1]),
1375        };
1376        let request = build_partition_assignment_request(
1377            &assignments,
1378            krabka_units::secs(30),
1379            AlterPartitionReassignmentsOptions::default(),
1380        );
1381        assert2::assert!(
1382            request
1383                == AlterPartitionReassignmentsRequest {
1384                    timeout_ms: 30_000,
1385                    allow_replication_factor_change: true,
1386                    topics: vec![ReassignableTopic {
1387                        name: "orders".into(),
1388                        partitions: vec![ReassignablePartition {
1389                            partition_index: 3,
1390                            replicas: Some(vec![4, 2, 1]),
1391                            ..Default::default()
1392                        }],
1393                        ..Default::default()
1394                    }],
1395                    ..Default::default()
1396                }
1397        );
1398    }
1399
1400    /// Kafka's `AlterPartitionReassignmentsOptions.allowReplicationFactorChange`
1401    /// reaches the wire as it is set. `false` needs v1: against a v0-only
1402    /// controller the call is refused before anything is sent, as Kafka's
1403    /// request builder throws `UnsupportedVersionException`.
1404    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1405    async fn allow_replication_factor_change_reaches_the_wire_and_needs_v1_when_false() {
1406        use krabka_client_core::{ClientError, MockBroker};
1407        use krabka_protocol::owned::{
1408            alter_partition_reassignments_request,
1409            alter_partition_reassignments_response::AlterPartitionReassignmentsResponse,
1410            api_versions_request,
1411        };
1412
1413        use crate::partition_leaders::test_support::{
1414            ApiVersionRange, api_versions, decode_request, encode_response,
1415        };
1416
1417        // `None` calls `alter_partition_assignments`, which takes Kafka's
1418        // default, and `Some(allow)` calls `alter_partition_assignments_with`.
1419        for (name, max_version, allow, expected) in [
1420            ("plain call at v1", 1, None, Ok(vec![(1, true)])),
1421            ("default at v1", 1, Some(true), Ok(vec![(1, true)])),
1422            (
1423                "refuse a change at v1",
1424                1,
1425                Some(false),
1426                Ok(vec![(1, false)]),
1427            ),
1428            ("default at v0", 0, Some(true), Ok(vec![(0, true)])),
1429            ("refuse a change at v0", 0, Some(false), Err(())),
1430        ] {
1431            let seen = Arc::new(Mutex::new(Vec::new()));
1432            let requests = Arc::clone(&seen);
1433            let broker = MockBroker::start(move |api_key, version, _, body| match api_key {
1434                api_versions_request::API_KEY => Some(
1435                    api_versions()
1436                        .apis(vec![
1437                            ApiVersionRange::builder()
1438                                .api_key(alter_partition_reassignments_request::API_KEY)
1439                                .max_version(max_version)
1440                                .build(),
1441                        ])
1442                        .call(),
1443                ),
1444                alter_partition_reassignments_request::API_KEY => {
1445                    let request: AlterPartitionReassignmentsRequest =
1446                        decode_request(body, version, true);
1447                    requests
1448                        .lock()
1449                        .expect("requests lock")
1450                        .push((version, request.allow_replication_factor_change));
1451                    Some(encode_response(
1452                        &AlterPartitionReassignmentsResponse::default(),
1453                        version,
1454                        true,
1455                    ))
1456                }
1457                _ => None,
1458            })
1459            .await
1460            .unwrap();
1461            let mut admin = AdminClient::connect(&[broker.addr.to_string()])
1462                .await
1463                .expect("admin connects");
1464
1465            let assignments = maplit::btreemap! {
1466                ("orders".into(), 0) => Some(vec![1, 2]),
1467            };
1468            let timeout = krabka_units::secs(5);
1469            let outcome = match allow {
1470                None => {
1471                    admin
1472                        .alter_partition_assignments(&assignments, timeout)
1473                        .await
1474                }
1475                Some(allow) => {
1476                    admin
1477                        .alter_partition_assignments_with(
1478                            &assignments,
1479                            timeout,
1480                            AlterPartitionReassignmentsOptions {
1481                                allow_replication_factor_change: allow,
1482                            },
1483                        )
1484                        .await
1485                }
1486            };
1487            let result = outcome
1488                .map(|_| seen.lock().expect("requests lock").clone())
1489                .map_err(|error| {
1490                    assert2::assert!(
1491                        matches!(
1492                            error,
1493                            AdminError::Transport(ClientError::IncompatibleVersion { .. })
1494                        ),
1495                        "case {name}: {error:?}"
1496                    );
1497                });
1498
1499            broker.stop();
1500            assert2::assert!(result == expected, "case {name}");
1501        }
1502    }
1503
1504    fn reassignment_metadata(assignments: &[&[i32]]) -> MetadataResponse {
1505        MetadataResponse {
1506            brokers: (1..=3)
1507                .map(|node_id| MetadataResponseBroker {
1508                    node_id,
1509                    ..Default::default()
1510                })
1511                .collect(),
1512            topics: vec![MetadataResponseTopic {
1513                name: Some("orders".into()),
1514                partitions: assignments
1515                    .iter()
1516                    .enumerate()
1517                    .map(|(partition_index, replicas)| MetadataResponsePartition {
1518                        partition_index: i32::try_from(partition_index)
1519                            .expect("test partition index fits i32"),
1520                        replica_nodes: replicas.to_vec(),
1521                        ..Default::default()
1522                    })
1523                    .collect(),
1524                ..Default::default()
1525            }],
1526            ..Default::default()
1527        }
1528    }
1529
1530    #[test]
1531    fn replication_factor_reassignment_preserves_and_rotates_replicas() {
1532        let cases = [
1533            (
1534                "increase",
1535                reassignment_metadata(&[&[1], &[2]]),
1536                2,
1537                vec![(0, vec![1, 2]), (1, vec![2, 3])],
1538            ),
1539            (
1540                "decrease",
1541                reassignment_metadata(&[&[1, 2, 3], &[2, 3, 1]]),
1542                2,
1543                vec![(0, vec![1, 2]), (1, vec![2, 3])],
1544            ),
1545        ];
1546
1547        for (case, metadata, replication_factor, expected_partitions) in cases {
1548            let actual = build_replication_factor_reassignment(
1549                &metadata,
1550                &[1, 2, 3],
1551                "orders",
1552                replication_factor,
1553                krabka_units::secs(5),
1554            )
1555            .unwrap();
1556            let expected = Some(AlterPartitionReassignmentsRequest {
1557                timeout_ms: 5_000,
1558                allow_replication_factor_change: true,
1559                topics: vec![ReassignableTopic {
1560                    name: "orders".into(),
1561                    partitions: expected_partitions
1562                        .into_iter()
1563                        .map(|(partition_index, replicas)| ReassignablePartition {
1564                            partition_index,
1565                            replicas: Some(replicas),
1566                            ..Default::default()
1567                        })
1568                        .collect(),
1569                    ..Default::default()
1570                }],
1571                ..Default::default()
1572            });
1573            assert2::assert!(actual == expected, "case {case}");
1574        }
1575    }
1576
1577    #[test]
1578    fn replication_factor_reassignment_rejects_factor_above_broker_count() {
1579        let error = build_replication_factor_reassignment(
1580            &reassignment_metadata(&[&[1]]),
1581            &[1, 2, 3],
1582            "orders",
1583            4,
1584            krabka_units::secs(5),
1585        )
1586        .unwrap_err();
1587
1588        assert2::assert!(matches!(
1589            error,
1590            AdminError::Broker {
1591                api: "AlterPartitionReassignments",
1592                code: 38,
1593                name: "INVALID_REPLICATION_FACTOR",
1594                ..
1595            }
1596        ));
1597    }
1598
1599    #[test]
1600    fn replication_factor_reassignment_replaces_fenced_replica() {
1601        let actual = build_replication_factor_reassignment(
1602            &reassignment_metadata(&[&[1, 2]]),
1603            &[1, 3],
1604            "orders",
1605            2,
1606            krabka_units::secs(5),
1607        )
1608        .unwrap()
1609        .expect("fenced replica requires reassignment");
1610
1611        assert2::assert!(actual.topics[0].partitions[0].replicas == Some(vec![1, 3]));
1612    }
1613
1614    #[test]
1615    fn build_create_topics_one_spec() {
1616        let req = build_create_topics(
1617            &[CreateTopicSpec::builder()
1618                .name("foo")
1619                .partitions(3)
1620                .replicas(1)
1621                .configs(maplit::btreemap! {
1622                    "retention.ms".to_owned() => "60000".to_owned(),
1623                })
1624                .build()],
1625            5_000,
1626        );
1627        assert2::assert!(
1628            req == CreateTopicsRequest {
1629                topics: vec![CreatableTopic {
1630                    name: "foo".to_owned(),
1631                    num_partitions: 3,
1632                    replication_factor: 1,
1633                    assignments: vec![],
1634                    configs: vec![CreatableTopicConfig {
1635                        name: "retention.ms".to_owned(),
1636                        value: Some("60000".to_owned()),
1637                        unknown_tagged_fields: UnknownTaggedFields(vec![]),
1638                    }],
1639                    unknown_tagged_fields: UnknownTaggedFields(vec![]),
1640                }],
1641                timeout_ms: 5_000,
1642                validate_only: false,
1643                unknown_tagged_fields: UnknownTaggedFields(vec![]),
1644            }
1645        );
1646    }
1647
1648    /// Kafka's `NewTopic.convertToCreatableTopic` sends one assignment row
1649    /// per partition index, and `createPartitions` sends the new partitions'
1650    /// broker lists in order, or a null list when none is given.
1651    #[test]
1652    fn replica_assignments_reach_the_wire_as_kafka_sends_them() {
1653        let topics = build_create_topics(
1654            &[CreateTopicSpec::builder()
1655                .name("placed")
1656                .partitions(-1)
1657                .replicas(-1)
1658                .replica_assignments(maplit::btreemap! {
1659                    1 => vec![3, 1],
1660                    0 => vec![1, 2],
1661                })
1662                .build()],
1663            5_000,
1664        );
1665        assert2::assert!(
1666            topics.topics
1667                == vec![CreatableTopic {
1668                    name: "placed".into(),
1669                    num_partitions: -1,
1670                    replication_factor: -1,
1671                    assignments: vec![
1672                        CreatableReplicaAssignment {
1673                            partition_index: 0,
1674                            broker_ids: vec![1, 2],
1675                            ..Default::default()
1676                        },
1677                        CreatableReplicaAssignment {
1678                            partition_index: 1,
1679                            broker_ids: vec![3, 1],
1680                            ..Default::default()
1681                        },
1682                    ],
1683                    ..Default::default()
1684                }]
1685        );
1686
1687        for (name, assignments, expected) in [
1688            ("broker placement", None, None),
1689            (
1690                "explicit placement",
1691                Some(vec![vec![2, 3], vec![3, 1]]),
1692                Some(vec![
1693                    CreatePartitionsAssignment {
1694                        broker_ids: vec![2, 3],
1695                        ..Default::default()
1696                    },
1697                    CreatePartitionsAssignment {
1698                        broker_ids: vec![3, 1],
1699                        ..Default::default()
1700                    },
1701                ]),
1702            ),
1703        ] {
1704            let request = build_create_partitions(
1705                &[CreatePartitionsOp {
1706                    name: "orders".into(),
1707                    new_total_count: 4,
1708                    assignments,
1709                }],
1710                5_000,
1711            );
1712            assert2::assert!(
1713                request.topics
1714                    == vec![CreatePartitionsTopic {
1715                        name: "orders".into(),
1716                        count: 4,
1717                        assignments: expected,
1718                        ..Default::default()
1719                    }],
1720                "case {name}"
1721            );
1722        }
1723    }
1724
1725    #[test]
1726    fn build_delete_records_groups_partitions_by_topic() {
1727        let req = build_delete_records(
1728            &[
1729                DeleteRecordsOp {
1730                    topic: "beta".to_owned(),
1731                    partition: 1,
1732                    offset: -1,
1733                },
1734                DeleteRecordsOp {
1735                    topic: "alpha".to_owned(),
1736                    partition: 0,
1737                    offset: 50,
1738                },
1739                DeleteRecordsOp {
1740                    topic: "alpha".to_owned(),
1741                    partition: 2,
1742                    offset: 75,
1743                },
1744            ],
1745            krabka_units::secs(5),
1746        );
1747
1748        assert2::assert!(
1749            req == DeleteRecordsRequest {
1750                topics: vec![
1751                    DeleteRecordsTopic {
1752                        name: "alpha".to_owned(),
1753                        partitions: vec![
1754                            DeleteRecordsPartition {
1755                                partition_index: 0,
1756                                offset: 50,
1757                                unknown_tagged_fields: UnknownTaggedFields(vec![]),
1758                            },
1759                            DeleteRecordsPartition {
1760                                partition_index: 2,
1761                                offset: 75,
1762                                unknown_tagged_fields: UnknownTaggedFields(vec![]),
1763                            },
1764                        ],
1765                        unknown_tagged_fields: UnknownTaggedFields(vec![]),
1766                    },
1767                    DeleteRecordsTopic {
1768                        name: "beta".to_owned(),
1769                        partitions: vec![DeleteRecordsPartition {
1770                            partition_index: 1,
1771                            offset: -1,
1772                            unknown_tagged_fields: UnknownTaggedFields(vec![]),
1773                        }],
1774                        unknown_tagged_fields: UnknownTaggedFields(vec![]),
1775                    },
1776                ],
1777                timeout_ms: 5_000,
1778                unknown_tagged_fields: UnknownTaggedFields(vec![]),
1779            }
1780        );
1781    }
1782
1783    #[test]
1784    fn parse_delete_records_flattens_partition_errors() {
1785        use krabka_protocol::owned::delete_records_response::{
1786            DeleteRecordsPartitionResult, DeleteRecordsResponse, DeleteRecordsTopicResult,
1787        };
1788
1789        let resp = DeleteRecordsResponse {
1790            topics: vec![DeleteRecordsTopicResult {
1791                name: "wal".to_owned(),
1792                partitions: vec![
1793                    DeleteRecordsPartitionResult {
1794                        partition_index: 0,
1795                        low_watermark: 50,
1796                        error_code: 0,
1797                        ..Default::default()
1798                    },
1799                    DeleteRecordsPartitionResult {
1800                        partition_index: 1,
1801                        low_watermark: -1,
1802                        error_code: 1,
1803                        ..Default::default()
1804                    },
1805                ],
1806                ..Default::default()
1807            }],
1808            ..Default::default()
1809        };
1810
1811        let outcomes = parse_delete_records(resp);
1812        assert_eq!(
1813            outcomes,
1814            vec![
1815                DeleteRecordsOutcome {
1816                    topic: "wal".to_owned(),
1817                    partition: 0,
1818                    error_code: 0,
1819                    low_watermark: 50,
1820                },
1821                DeleteRecordsOutcome {
1822                    topic: "wal".to_owned(),
1823                    partition: 1,
1824                    error_code: 1,
1825                    low_watermark: -1,
1826                },
1827            ]
1828        );
1829    }
1830
1831    // ── NOT_CONTROLLER retry predicate ─────────────────────────────
1832    //
1833    // The full retry pipeline (first response carries NOT_CONTROLLER →
1834    // refresh controller endpoint → reconnect → second response succeeds)
1835    // is exercised against a real broker in `tests/round_trip.rs`. The
1836    // unit tests below lock the two pure pieces — the predicate that
1837    // decides whether to retry, and the metadata-response → host:port
1838    // resolver — so a refactor can't silently flip either one.
1839
1840    /// Kafka's `handleNotControllerError` retries `NOT_CONTROLLER` (41),
1841    /// and `NOT_LEADER_OR_FOLLOWER` (6) only with bootstrap controllers.
1842    #[test]
1843    fn not_controller_codes_match_kafka() {
1844        let cases = [
1845            ((NOT_CONTROLLER, false), true),
1846            ((NOT_CONTROLLER, true), true),
1847            ((NOT_LEADER_OR_FOLLOWER, false), false),
1848            ((NOT_LEADER_OR_FOLLOWER, true), true),
1849            ((36, true), false),
1850            ((THROTTLING_QUOTA_EXCEEDED, false), false),
1851            ((0, false), false),
1852        ];
1853        let actual = cases
1854            .map(|((code, bootstrap), _)| ((code, bootstrap), is_not_controller(code, bootstrap)));
1855        assert2::assert!(actual == cases);
1856    }
1857
1858    // ── controller_endpoint resolver ───────────────────────────────
1859
1860    /// Spec test name: `connect_walks_bootstrap_list`, the resolver half. The
1861    /// bootstrap-walking integration coverage itself lives in
1862    /// `tests/connect.rs`. `controller_endpoint` extracts the `host:port` of
1863    /// the broker whose `node_id` matches the metadata response's
1864    /// `controller_id`. This is the address the `NOT_CONTROLLER` retry path
1865    /// reconnects to.
1866    #[test]
1867    fn controller_endpoint_picks_broker_with_matching_node_id() {
1868        use metadata_response::{MetadataResponse, MetadataResponseBroker};
1869        let resp = MetadataResponse {
1870            controller_id: 2,
1871            brokers: vec![
1872                MetadataResponseBroker {
1873                    node_id: 1,
1874                    host: "h1".into(),
1875                    port: 9092,
1876                    rack: None,
1877                    ..Default::default()
1878                },
1879                MetadataResponseBroker {
1880                    node_id: 2,
1881                    host: "h2".into(),
1882                    port: 9093,
1883                    rack: None,
1884                    ..Default::default()
1885                },
1886            ],
1887            ..Default::default()
1888        };
1889        let addr = controller_endpoint(&resp);
1890        assert2::assert!(addr.as_deref() == Some("h2:9093"));
1891    }
1892
1893    /// When the controller id does not appear in the broker list, for example
1894    /// when the cluster is mid-failover, `controller_endpoint` returns `None`.
1895    /// The retry path maps that to `AdminError::NotControllerExhausted`.
1896    #[test]
1897    fn controller_endpoint_returns_none_when_no_match() {
1898        use metadata_response::{MetadataResponse, MetadataResponseBroker};
1899        let resp = MetadataResponse {
1900            controller_id: 99,
1901            brokers: vec![MetadataResponseBroker {
1902                node_id: 1,
1903                host: "h1".into(),
1904                port: 9092,
1905                rack: None,
1906                ..Default::default()
1907            }],
1908            ..Default::default()
1909        };
1910        assert2::assert!(controller_endpoint(&resp).is_none());
1911    }
1912
1913    #[test]
1914    fn controller_endpoint_rejects_non_dialable_ephemeral_port() {
1915        use metadata_response::{MetadataResponse, MetadataResponseBroker};
1916        let resp = MetadataResponse {
1917            controller_id: 1,
1918            brokers: vec![MetadataResponseBroker {
1919                node_id: 1,
1920                host: "127.0.0.1".into(),
1921                port: 0,
1922                ..Default::default()
1923            }],
1924            ..Default::default()
1925        };
1926
1927        assert2::assert!(controller_endpoint(&resp).is_none());
1928        assert2::assert!(controller_requires_bootstrap_fallback(&resp));
1929    }
1930
1931    // ── parse_metadata ─────────────────────────────────────────────────
1932    //
1933    // `parse_metadata` is the pure response→`TopicMetadata` transformer
1934    // the live `metadata` RPC delegates to. The tests below feed it
1935    // synthetic responses and assert the per-topic fields are projected
1936    // correctly. Covers the error-mapping, uuid-zeroing, and
1937    // partition/replication-factor count paths.
1938
1939    #[test]
1940    fn parse_metadata_carries_through_per_topic_errors() {
1941        use metadata_response::{MetadataResponse, MetadataResponseTopic};
1942        let resp = MetadataResponse {
1943            topics: vec![
1944                MetadataResponseTopic {
1945                    name: Some("ok-topic".into()),
1946                    error_code: 0,
1947                    ..Default::default()
1948                },
1949                MetadataResponseTopic {
1950                    name: Some("missing".into()),
1951                    error_code: 3, // UNKNOWN_TOPIC_OR_PARTITION
1952                    ..Default::default()
1953                },
1954            ],
1955            ..Default::default()
1956        };
1957        let md = parse_metadata(resp);
1958        assert2::assert!(
1959            md == TopicMetadata {
1960                controller_id: -1,
1961                topics: vec![
1962                    TopicMetadataEntry {
1963                        name: "ok-topic".to_owned(),
1964                        topic_id: None,
1965                        partition_count: 0,
1966                        replication_factor: 0,
1967                        error: None,
1968                    },
1969                    TopicMetadataEntry {
1970                        name: "missing".to_owned(),
1971                        topic_id: None,
1972                        partition_count: 0,
1973                        replication_factor: 0,
1974                        error: Some(KafkaError {
1975                            code: 3,
1976                            name: "UNKNOWN_TOPIC_OR_PARTITION",
1977                            message: None,
1978                        }),
1979                    },
1980                ],
1981            }
1982        );
1983    }
1984
1985    #[test]
1986    fn parse_metadata_zero_uuid_becomes_none() {
1987        use metadata_response::{MetadataResponse, MetadataResponseTopic};
1988        let resp = MetadataResponse {
1989            topics: vec![MetadataResponseTopic {
1990                name: Some("foo".into()),
1991                topic_id: ProtoUuid::ZERO,
1992                ..Default::default()
1993            }],
1994            ..Default::default()
1995        };
1996        let md = parse_metadata(resp);
1997        assert2::assert!(md.topics[0].topic_id.is_none());
1998    }
1999
2000    #[test]
2001    fn parse_metadata_computes_partition_count_and_replication_factor() {
2002        use metadata_response::{
2003            MetadataResponse, MetadataResponsePartition, MetadataResponseTopic,
2004        };
2005        let part = MetadataResponsePartition {
2006            replica_nodes: vec![1, 2],
2007            ..Default::default()
2008        };
2009        let resp = MetadataResponse {
2010            topics: vec![MetadataResponseTopic {
2011                name: Some("foo".into()),
2012                partitions: vec![part.clone(), part.clone(), part],
2013                ..Default::default()
2014            }],
2015            ..Default::default()
2016        };
2017        let md = parse_metadata(resp);
2018        assert2::assert!(
2019            (
2020                md.topics[0].partition_count,
2021                md.topics[0].replication_factor
2022            ) == (3, 2)
2023        );
2024    }
2025
2026    // ── parse_create_topics ────────────────────────────────────────────
2027
2028    #[test]
2029    fn parse_create_topics_per_topic_error() {
2030        use krabka_protocol::owned::create_topics_response::{
2031            CreatableTopicResult, CreateTopicsResponse,
2032        };
2033        let resp = CreateTopicsResponse {
2034            topics: vec![
2035                CreatableTopicResult {
2036                    name: "ok".into(),
2037                    topic_id: ProtoUuid([7; 16]),
2038                    error_code: 0,
2039                    error_message: None,
2040                    ..Default::default()
2041                },
2042                CreatableTopicResult {
2043                    name: "dup".into(),
2044                    error_code: 36, // TOPIC_ALREADY_EXISTS
2045                    error_message: Some("already there".into()),
2046                    ..Default::default()
2047                },
2048                CreatableTopicResult {
2049                    name: "throttled".into(),
2050                    error_code: THROTTLING_QUOTA_EXCEEDED,
2051                    error_message: None,
2052                    ..Default::default()
2053                },
2054            ],
2055            throttle_time_ms: 250,
2056            ..Default::default()
2057        };
2058        let outcomes = parse_create_topics(resp);
2059        assert2::assert!(
2060            outcomes
2061                == vec![
2062                    CreateTopicOutcome {
2063                        name: "ok".to_owned(),
2064                        // Non-zero uuid maps to Some.
2065                        topic_id: Some(Uuid::from_bytes([7; 16])),
2066                        error: None,
2067                        throttle_time: None,
2068                    },
2069                    CreateTopicOutcome {
2070                        name: "dup".to_owned(),
2071                        topic_id: None,
2072                        error: Some(KafkaError {
2073                            code: 36,
2074                            name: "TOPIC_ALREADY_EXISTS",
2075                            message: Some("already there".to_owned()),
2076                        }),
2077                        throttle_time: None,
2078                    },
2079                    CreateTopicOutcome {
2080                        name: "throttled".to_owned(),
2081                        topic_id: None,
2082                        error: Some(KafkaError {
2083                            code: THROTTLING_QUOTA_EXCEEDED,
2084                            name: "THROTTLING_QUOTA_EXCEEDED",
2085                            message: None,
2086                        }),
2087                        throttle_time: Some(Time::from_millis(250)),
2088                    },
2089                ]
2090        );
2091    }
2092
2093    // ── parse_delete_topics ────────────────────────────────────────────
2094
2095    #[test]
2096    fn parse_delete_topics_handles_missing_name() {
2097        use krabka_protocol::owned::delete_topics_response::{
2098            DeletableTopicResult, DeleteTopicsResponse,
2099        };
2100        let resp = DeleteTopicsResponse {
2101            responses: vec![
2102                DeletableTopicResult {
2103                    name: None,
2104                    error_code: 0,
2105                    ..Default::default()
2106                },
2107                DeletableTopicResult {
2108                    name: Some("named".into()),
2109                    error_code: 3,
2110                    error_message: Some("nope".into()),
2111                    ..Default::default()
2112                },
2113            ],
2114            ..Default::default()
2115        };
2116        let outs = parse_delete_topics(resp);
2117        assert2::assert!(
2118            outs == vec![
2119                DeleteTopicOutcome {
2120                    // `name: None` falls through to `unwrap_or_default()`
2121                    // → empty string.
2122                    name: String::new(),
2123                    error: None,
2124                    throttle_time: None,
2125                },
2126                DeleteTopicOutcome {
2127                    name: "named".to_owned(),
2128                    error: Some(KafkaError {
2129                        code: 3,
2130                        name: "UNKNOWN_TOPIC_OR_PARTITION",
2131                        message: Some("nope".to_owned()),
2132                    }),
2133                    throttle_time: None,
2134                },
2135            ]
2136        );
2137    }
2138
2139    // ── parse_create_partitions ────────────────────────────────────────
2140
2141    #[test]
2142    fn parse_create_partitions_per_topic_error() {
2143        use krabka_protocol::owned::create_partitions_response::{
2144            CreatePartitionsResponse, CreatePartitionsTopicResult,
2145        };
2146        let resp = CreatePartitionsResponse {
2147            results: vec![
2148                CreatePartitionsTopicResult {
2149                    name: "ok".into(),
2150                    error_code: 0,
2151                    error_message: None,
2152                    ..Default::default()
2153                },
2154                CreatePartitionsTopicResult {
2155                    name: "bad".into(),
2156                    error_code: 37,
2157                    error_message: Some("bad count".into()),
2158                    ..Default::default()
2159                },
2160            ],
2161            ..Default::default()
2162        };
2163        let outs = parse_create_partitions(resp);
2164        assert2::assert!(
2165            outs == vec![
2166                CreatePartitionsOutcome {
2167                    name: "ok".to_owned(),
2168                    error: None,
2169                    throttle_time: None,
2170                },
2171                CreatePartitionsOutcome {
2172                    name: "bad".to_owned(),
2173                    error: Some(KafkaError {
2174                        code: 37,
2175                        name: "INVALID_PARTITIONS",
2176                        message: Some("bad count".to_owned()),
2177                    }),
2178                    throttle_time: None,
2179                },
2180            ]
2181        );
2182    }
2183
2184    // ── topic mutation retries against a scripted controller ──────────
2185
2186    use std::sync::{
2187        Arc, Mutex,
2188        atomic::{AtomicBool, AtomicU16, Ordering},
2189    };
2190
2191    use bytes::{Buf, BytesMut};
2192    use krabka_client_core::{MockBroker, MockReply};
2193    use krabka_protocol::{
2194        Decode, Encode,
2195        owned::{
2196            api_versions_request,
2197            api_versions_response::{ApiVersion, ApiVersionsResponse},
2198            create_partitions_request,
2199            create_partitions_response::{CreatePartitionsResponse, CreatePartitionsTopicResult},
2200            create_topics_request,
2201            create_topics_response::{CreatableTopicResult, CreateTopicsResponse},
2202            delete_topics_request,
2203            delete_topics_response::{DeletableTopicResult, DeleteTopicsResponse},
2204            metadata_request,
2205        },
2206    };
2207
2208    /// The topic mutation call of a retry case.
2209    #[derive(Clone, Copy, Debug)]
2210    enum Mutation {
2211        CreateTopics,
2212        DeleteTopics,
2213        CreatePartitions,
2214    }
2215
2216    /// One scripted controller answer: the code of each topic that it names,
2217    /// and the response throttle time. A topic that is not in the list gets
2218    /// no result.
2219    #[derive(Clone, Debug)]
2220    struct Answer {
2221        codes: Vec<(&'static str, i16)>,
2222        throttle_time_ms: i32,
2223        /// The controller does not answer the request.
2224        silent: bool,
2225        /// The controller closes the connection with no answer.
2226        closed: bool,
2227    }
2228
2229    fn answer(codes: &[(&'static str, i16)]) -> Answer {
2230        Answer {
2231            codes: codes.to_vec(),
2232            throttle_time_ms: 0,
2233            silent: false,
2234            closed: false,
2235        }
2236    }
2237
2238    fn throttled(codes: &[(&'static str, i16)], throttle_time_ms: i32) -> Answer {
2239        Answer {
2240            codes: codes.to_vec(),
2241            throttle_time_ms,
2242            silent: false,
2243            closed: false,
2244        }
2245    }
2246
2247    fn silent() -> Answer {
2248        Answer {
2249            codes: Vec::new(),
2250            throttle_time_ms: 0,
2251            silent: true,
2252            closed: false,
2253        }
2254    }
2255
2256    fn closed() -> Answer {
2257        Answer {
2258            codes: Vec::new(),
2259            throttle_time_ms: 0,
2260            silent: false,
2261            closed: true,
2262        }
2263    }
2264
2265    /// The topic names and the `timeout_ms` of one request.
2266    type SentRequest = (Vec<String>, i32);
2267
2268    /// One topic mutation retry case: name, controller script, deadline,
2269    /// options, expected requests, expected outcomes.
2270    type MutationCase = (
2271        &'static str,
2272        Vec<Answer>,
2273        Duration,
2274        TopicMutationOptions,
2275        Vec<Vec<String>>,
2276        Vec<Observed>,
2277    );
2278
2279    /// An outcome in a form that tests compare: the topic, the code, and
2280    /// whether the outcome has a throttle time.
2281    type Observed = (String, i16, bool);
2282
2283    fn body(response: &impl Encode, version: i16, flexible: bool) -> Vec<u8> {
2284        let mut bytes = BytesMut::new();
2285        if flexible {
2286            bytes.extend_from_slice(&[0]);
2287        }
2288        response
2289            .encode(&mut bytes, version)
2290            .expect("response encodes");
2291        bytes.to_vec()
2292    }
2293
2294    fn decode_body<R: for<'de> Decode<'de>>(mut body: &[u8], version: i16, flexible: bool) -> R {
2295        let client_id_len = body.get_i16();
2296        body.advance(usize::try_from(client_id_len).expect("client id length"));
2297        if flexible {
2298            body.advance(1);
2299        }
2300        R::decode(&mut body, version).expect("request decodes")
2301    }
2302
2303    /// A broker that is its own controller. It answers each topic mutation
2304    /// with the next entry of `script` (the last entry repeats), and records
2305    /// the topic names and the `timeout_ms` of each request.
2306    async fn scripted_controller(
2307        script: Vec<Answer>,
2308        requests: Arc<Mutex<Vec<SentRequest>>>,
2309    ) -> MockBroker {
2310        let port = Arc::new(AtomicU16::new(0));
2311        let handler_port = Arc::clone(&port);
2312        let mut next = 0_usize;
2313        let close = Arc::new(AtomicBool::new(false));
2314        let handler_close = Arc::clone(&close);
2315        let mut handler = move |api_key: i16, version: i16, request: &[u8]| {
2316            let api_version = |api_key, max_version| ApiVersion {
2317                api_key,
2318                min_version: 0,
2319                max_version,
2320                ..Default::default()
2321            };
2322            let mut take = |names: Vec<String>, timeout_ms: i32| {
2323                requests
2324                    .lock()
2325                    .expect("requests lock")
2326                    .push((names, timeout_ms));
2327                let entry = script[next.min(script.len() - 1)].clone();
2328                next += 1;
2329                handler_close.store(entry.closed, Ordering::SeqCst);
2330                entry
2331            };
2332            match api_key {
2333                api_versions_request::API_KEY => Some(body(
2334                    &ApiVersionsResponse {
2335                        api_keys: vec![
2336                            api_version(api_versions_request::API_KEY, 0),
2337                            api_version(metadata_request::API_KEY, 12),
2338                            api_version(create_topics_request::API_KEY, 7),
2339                            api_version(delete_topics_request::API_KEY, 6),
2340                            api_version(create_partitions_request::API_KEY, 3),
2341                        ],
2342                        ..Default::default()
2343                    },
2344                    0,
2345                    false,
2346                )),
2347                metadata_request::API_KEY => Some(body(
2348                    &MetadataResponse {
2349                        controller_id: 1,
2350                        brokers: vec![MetadataResponseBroker {
2351                            node_id: 1,
2352                            host: "127.0.0.1".into(),
2353                            port: i32::from(handler_port.load(Ordering::SeqCst)),
2354                            ..Default::default()
2355                        }],
2356                        ..Default::default()
2357                    },
2358                    version,
2359                    version >= metadata_request::FLEXIBLE_MIN,
2360                )),
2361                create_topics_request::API_KEY => {
2362                    let flexible = version >= create_topics_request::FLEXIBLE_MIN;
2363                    let request: CreateTopicsRequest = decode_body(request, version, flexible);
2364                    let entry = take(
2365                        request
2366                            .topics
2367                            .iter()
2368                            .map(|topic| topic.name.clone())
2369                            .collect(),
2370                        request.timeout_ms,
2371                    );
2372                    if entry.silent {
2373                        return None;
2374                    }
2375                    Some(body(
2376                        &CreateTopicsResponse {
2377                            throttle_time_ms: entry.throttle_time_ms,
2378                            topics: entry
2379                                .codes
2380                                .iter()
2381                                .map(|(name, code)| CreatableTopicResult {
2382                                    name: (*name).to_owned(),
2383                                    error_code: *code,
2384                                    ..Default::default()
2385                                })
2386                                .collect(),
2387                            ..Default::default()
2388                        },
2389                        version,
2390                        flexible,
2391                    ))
2392                }
2393                delete_topics_request::API_KEY => {
2394                    let flexible = version >= delete_topics_request::FLEXIBLE_MIN;
2395                    let request: DeleteTopicsRequest = decode_body(request, version, flexible);
2396                    let entry = take(
2397                        request
2398                            .topics
2399                            .iter()
2400                            .map(|topic| topic.name.clone().unwrap_or_default())
2401                            .collect(),
2402                        request.timeout_ms,
2403                    );
2404                    if entry.silent {
2405                        return None;
2406                    }
2407                    Some(body(
2408                        &DeleteTopicsResponse {
2409                            throttle_time_ms: entry.throttle_time_ms,
2410                            responses: entry
2411                                .codes
2412                                .iter()
2413                                .map(|(name, code)| DeletableTopicResult {
2414                                    name: Some((*name).to_owned()),
2415                                    error_code: *code,
2416                                    ..Default::default()
2417                                })
2418                                .collect(),
2419                            ..Default::default()
2420                        },
2421                        version,
2422                        flexible,
2423                    ))
2424                }
2425                create_partitions_request::API_KEY => {
2426                    let flexible = version >= create_partitions_request::FLEXIBLE_MIN;
2427                    let request: CreatePartitionsRequest = decode_body(request, version, flexible);
2428                    let entry = take(
2429                        request
2430                            .topics
2431                            .iter()
2432                            .map(|topic| topic.name.clone())
2433                            .collect(),
2434                        request.timeout_ms,
2435                    );
2436                    if entry.silent {
2437                        return None;
2438                    }
2439                    Some(body(
2440                        &CreatePartitionsResponse {
2441                            throttle_time_ms: entry.throttle_time_ms,
2442                            results: entry
2443                                .codes
2444                                .iter()
2445                                .map(|(name, code)| CreatePartitionsTopicResult {
2446                                    name: (*name).to_owned(),
2447                                    error_code: *code,
2448                                    ..Default::default()
2449                                })
2450                                .collect(),
2451                            ..Default::default()
2452                        },
2453                        version,
2454                        flexible,
2455                    ))
2456                }
2457                _ => None,
2458            }
2459        };
2460        let broker = MockBroker::start_with_replies(move |api_key, version, _, request| {
2461            let reply = handler(api_key, version, request);
2462            if close.swap(false, Ordering::SeqCst) {
2463                return MockReply::Close;
2464            }
2465            reply.map_or(MockReply::Silent, MockReply::Respond)
2466        })
2467        .await
2468        .unwrap();
2469        port.store(broker.addr.port(), Ordering::SeqCst);
2470        broker
2471    }
2472
2473    fn observe<T: TopicMutationOutcome>(outcomes: Vec<T>) -> Vec<Observed> {
2474        outcomes
2475            .into_iter()
2476            .map(|outcome| {
2477                (
2478                    outcome.topic().to_owned(),
2479                    outcome.error_code(),
2480                    !outcome.throttle_duration().is_zero(),
2481                )
2482            })
2483            .collect()
2484    }
2485
2486    async fn run_mutation(
2487        admin: &AdminClient,
2488        mutation: Mutation,
2489        topics: &[&str],
2490        options: TopicMutationOptions,
2491        policy: RetryPolicy,
2492    ) -> Vec<Observed> {
2493        match mutation {
2494            Mutation::CreateTopics => {
2495                let specs = topics
2496                    .iter()
2497                    .map(|name| {
2498                        CreateTopicSpec::builder()
2499                            .name(*name)
2500                            .partitions(1)
2501                            .replicas(1)
2502                            .build()
2503                    })
2504                    .collect::<Vec<_>>();
2505                observe(
2506                    admin
2507                        .create_topics_with_retry(&specs, options, policy)
2508                        .await
2509                        .expect("create_topics"),
2510                )
2511            }
2512            Mutation::DeleteTopics => observe(
2513                admin
2514                    .delete_topics_with_retry(topics, options, policy)
2515                    .await
2516                    .expect("delete_topics"),
2517            ),
2518            Mutation::CreatePartitions => {
2519                let ops = topics
2520                    .iter()
2521                    .map(|name| CreatePartitionsOp {
2522                        name: (*name).to_owned(),
2523                        new_total_count: 2,
2524                        ..Default::default()
2525                    })
2526                    .collect::<Vec<_>>();
2527                observe(
2528                    admin
2529                        .create_partitions_with_retry(&ops, options, policy)
2530                        .await
2531                        .expect("create_partitions"),
2532                )
2533            }
2534        }
2535    }
2536
2537    /// Kafka's `getCreateTopicsCall`, `getDeleteTopicsCall` and
2538    /// `getCreatePartitionsCall` retry `NOT_CONTROLLER` (41) for the whole
2539    /// call through `handleNotControllerError`, and retry
2540    /// `THROTTLING_QUOTA_EXCEEDED` (89) for the throttled topics only when
2541    /// `retryOnQuotaViolation` is set. At the deadline a throttled topic keeps
2542    /// 89 with its throttle time, and another topic gets a `TimeoutException`
2543    /// (7).
2544    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2545    async fn topic_mutations_retry_not_controller_and_quota_as_kafka_does() {
2546        const LONG: Duration = Duration::from_secs(5);
2547        // One request fits in `NOW`, and the backoff after it reaches the
2548        // deadline, so the call sends no second request.
2549        const NOW: Duration = Duration::from_millis(500);
2550        let ok = |name: &str| (name.to_owned(), 0, false);
2551        let failed = |name: &str, code| (name.to_owned(), code, false);
2552        let quota = |name: &str| (name.to_owned(), THROTTLING_QUOTA_EXCEEDED, true);
2553        let names = |lists: &[&[&str]]| {
2554            lists
2555                .iter()
2556                .map(|list| {
2557                    list.iter()
2558                        .map(|name| (*name).to_owned())
2559                        .collect::<Vec<_>>()
2560                })
2561                .collect::<Vec<_>>()
2562        };
2563        let retry_quota = TopicMutationOptions::default();
2564        let no_quota_retry = TopicMutationOptions {
2565            retry_on_quota_violation: false,
2566            ..TopicMutationOptions::default()
2567        };
2568        let cases: Vec<MutationCase> = vec![
2569            (
2570                "no error",
2571                vec![answer(&[("a", 0), ("b", 0)])],
2572                LONG,
2573                retry_quota,
2574                names(&[&["a", "b"]]),
2575                vec![ok("a"), ok("b")],
2576            ),
2577            (
2578                "not controller twice, then success",
2579                vec![
2580                    answer(&[("a", 41), ("b", 41)]),
2581                    answer(&[("a", 41), ("b", 41)]),
2582                    answer(&[("a", 0), ("b", 0)]),
2583                ],
2584                LONG,
2585                retry_quota,
2586                names(&[&["a", "b"], &["a", "b"], &["a", "b"]]),
2587                vec![ok("a"), ok("b")],
2588            ),
2589            (
2590                "not controller for one topic sends every topic again",
2591                vec![
2592                    answer(&[("a", 0), ("b", 41)]),
2593                    answer(&[("a", 0), ("b", 0)]),
2594                ],
2595                LONG,
2596                retry_quota,
2597                names(&[&["a", "b"], &["a", "b"]]),
2598                vec![ok("a"), ok("b")],
2599            ),
2600            (
2601                "quota for one topic sends only that topic again",
2602                vec![answer(&[("a", 0), ("b", 89)]), answer(&[("b", 0)])],
2603                LONG,
2604                retry_quota,
2605                names(&[&["a", "b"], &["b"]]),
2606                vec![ok("a"), ok("b")],
2607            ),
2608            (
2609                "quota with retry off is final",
2610                vec![throttled(&[("a", 89), ("b", 0)], 250)],
2611                LONG,
2612                no_quota_retry,
2613                names(&[&["a", "b"]]),
2614                vec![quota("a"), ok("b")],
2615            ),
2616            (
2617                "not controller past the deadline times out",
2618                vec![answer(&[("a", 41), ("b", 41)])],
2619                NOW,
2620                retry_quota,
2621                names(&[&["a", "b"]]),
2622                vec![failed("a", 7), failed("b", 7)],
2623            ),
2624            (
2625                "quota past the deadline keeps the quota error",
2626                vec![throttled(&[("a", 89), ("b", 36)], 60_000)],
2627                Duration::from_millis(100),
2628                retry_quota,
2629                names(&[&["a", "b"]]),
2630                vec![quota("a"), failed("b", 36)],
2631            ),
2632            (
2633                "a silent controller stops the call at the deadline",
2634                vec![silent()],
2635                Duration::from_millis(300),
2636                retry_quota,
2637                names(&[&["a", "b"]]),
2638                vec![failed("a", 7), failed("b", 7)],
2639            ),
2640            (
2641                "a lost connection is retried after the backoff",
2642                vec![closed(), closed(), closed(), answer(&[("a", 0), ("b", 0)])],
2643                LONG,
2644                retry_quota,
2645                names(&[&["a", "b"], &["a", "b"], &["a", "b"], &["a", "b"]]),
2646                vec![ok("a"), ok("b")],
2647            ),
2648            (
2649                "a topic with no result fails",
2650                vec![answer(&[("a", 0)])],
2651                LONG,
2652                retry_quota,
2653                names(&[&["a", "b"]]),
2654                vec![ok("a"), failed("b", -1)],
2655            ),
2656        ];
2657        for mutation in [
2658            Mutation::CreateTopics,
2659            Mutation::DeleteTopics,
2660            Mutation::CreatePartitions,
2661        ] {
2662            for (name, script, timeout, options, expected_requests, expected) in cases.clone() {
2663                let requests = Arc::new(Mutex::new(Vec::new()));
2664                let controller = scripted_controller(script, Arc::clone(&requests)).await;
2665                let admin = AdminClient::connect(&[controller.addr.to_string()])
2666                    .await
2667                    .expect("admin connects");
2668                let backoff = if timeout == LONG {
2669                    Duration::from_millis(1)
2670                } else {
2671                    timeout
2672                };
2673                let policy = RetryPolicy {
2674                    timeout,
2675                    initial_backoff: backoff,
2676                    max_backoff: backoff,
2677                    jitter: 0.0,
2678                    max_retries: u32::MAX,
2679                };
2680
2681                let started = Instant::now();
2682                let observed = run_mutation(&admin, mutation, &["a", "b"], options, policy).await;
2683                let within_deadline = started.elapsed() < timeout + Duration::from_secs(2);
2684
2685                controller.stop();
2686                let requests = requests.lock().expect("requests lock").clone();
2687                let timeouts_within_deadline = requests.iter().all(|(_, timeout_ms)| {
2688                    u128::try_from(*timeout_ms).is_ok_and(|ms| ms <= timeout.as_millis())
2689                });
2690                let sent = requests
2691                    .into_iter()
2692                    .map(|(names, _)| names)
2693                    .collect::<Vec<_>>();
2694                assert2::assert!(
2695                    (sent, observed, timeouts_within_deadline, within_deadline)
2696                        == (expected_requests, expected, true, true),
2697                    "{mutation:?}: {name}"
2698                );
2699            }
2700        }
2701    }
2702
2703    /// Kafka completes a throttled topic at the deadline with the throttle
2704    /// time that remains (`maybeCompleteQuotaExceededException`).
2705    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2706    async fn quota_error_at_the_deadline_carries_the_remaining_throttle_time() {
2707        let requests = Arc::new(Mutex::new(Vec::new()));
2708        let controller =
2709            scripted_controller(vec![throttled(&[("a", 89)], 60_000)], Arc::clone(&requests)).await;
2710        let admin = AdminClient::connect(&[controller.addr.to_string()])
2711            .await
2712            .expect("admin connects");
2713        let policy = RetryPolicy {
2714            timeout: Duration::from_millis(100),
2715            ..KAFKA_ADMIN_RETRY
2716        };
2717
2718        let outcomes = admin
2719            .delete_topics_with_retry(&["a"], TopicMutationOptions::default(), policy)
2720            .await
2721            .expect("delete_topics");
2722
2723        controller.stop();
2724        let [outcome] = outcomes.as_slice() else {
2725            panic!("one outcome expected, got {outcomes:?}");
2726        };
2727        let remaining = outcome.throttle_duration();
2728        assert2::assert!(
2729            (
2730                outcome.error.as_ref().map(|error| error.code),
2731                remaining > Duration::from_secs(59),
2732                remaining < Duration::from_mins(1),
2733            ) == (Some(THROTTLING_QUOTA_EXCEEDED), true, true)
2734        );
2735    }
2736
2737    /// The `AdminClientLike` topic calls reach the controller under the timeout
2738    /// of their options. `delete_records` sends nothing for no partition, and
2739    /// gives a partition of a topic that the metadata does not name
2740    /// `UNKNOWN_TOPIC_OR_PARTITION` (3).
2741    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2742    async fn admin_client_like_topic_calls_reach_the_controller() {
2743        let requests = Arc::new(Mutex::new(Vec::new()));
2744        let controller =
2745            scripted_controller(vec![answer(&[("a", 0)])], Arc::clone(&requests)).await;
2746        let mut admin = AdminClient::connect(&[controller.addr.to_string()])
2747            .await
2748            .expect("admin connects");
2749        let options = TopicMutationOptions::with_timeout(krabka_units::secs(5));
2750
2751        let deleted = observe(
2752            admin
2753                .delete_topics(&["a"], options)
2754                .await
2755                .expect("delete_topics"),
2756        );
2757        let grown = observe(
2758            admin
2759                .create_partitions(
2760                    &[CreatePartitionsOp {
2761                        name: "a".to_owned(),
2762                        new_total_count: 2,
2763                        ..Default::default()
2764                    }],
2765                    options,
2766                )
2767                .await
2768                .expect("create_partitions"),
2769        );
2770        let no_records = admin
2771            .delete_records(&[], krabka_units::secs(5))
2772            .await
2773            .expect("delete_records of nothing");
2774        let unknown_topic = admin
2775            .delete_records(
2776                &[DeleteRecordsOp {
2777                    topic: "missing".to_owned(),
2778                    partition: 0,
2779                    offset: 10,
2780                }],
2781                krabka_units::secs(5),
2782            )
2783            .await
2784            .expect("delete_records of a missing topic");
2785        let assignments = admin
2786            .describe_partition_assignments(&["missing"])
2787            .await
2788            .expect("describe_partition_assignments");
2789
2790        controller.stop();
2791        let sent = requests
2792            .lock()
2793            .expect("requests lock")
2794            .iter()
2795            .map(|(names, timeout_ms)| (names.clone(), *timeout_ms <= 5_000))
2796            .collect::<Vec<_>>();
2797        assert2::assert!(
2798            (deleted, grown, sent)
2799                == (
2800                    vec![("a".to_owned(), 0, false)],
2801                    vec![("a".to_owned(), 0, false)],
2802                    vec![(vec!["a".to_owned()], true), (vec!["a".to_owned()], true)],
2803                )
2804        );
2805        assert2::assert!(
2806            (no_records, unknown_topic, assignments)
2807                == (
2808                    Vec::new(),
2809                    vec![DeleteRecordsOutcome {
2810                        topic: "missing".to_owned(),
2811                        partition: 0,
2812                        error_code: 3,
2813                        low_watermark: -1,
2814                    }],
2815                    Vec::new(),
2816                )
2817        );
2818    }
2819
2820    fn assert_send<T: Send>(_: T) {}
2821
2822    /// A caller can spawn every controller call on a multi-thread runtime.
2823    #[test]
2824    fn controller_call_futures_are_send() {
2825        let _ = |admin: &mut AdminClient, specs: &[CreateTopicSpec], names: &[&str]| {
2826            assert_send(admin.create_topics(specs, TopicMutationOptions::default()));
2827            assert_send(admin.delete_topics(names, TopicMutationOptions::default()));
2828            assert_send(admin.create_partitions(&[], TopicMutationOptions::default()));
2829            assert_send(admin.unregister_broker(1));
2830            assert_send(admin.update_features(
2831                &BTreeMap::new(),
2832                crate::UpdateFeaturesOptions::builder().build(),
2833            ));
2834            assert_send(admin.alter_partition_assignments(&BTreeMap::new(), krabka_units::secs(1)));
2835            assert_send(admin.reconcile_topic_replication_factor("t", 1, krabka_units::secs(1)));
2836        };
2837    }
2838}