Skip to main content

barnabas_client/
admin.rs

1//! The admin client: enough to create a topic, inspect a cluster, and trim a
2//! log — no more.
3//!
4//! **Two routing rules, and both are load-bearing:**
5//!
6//! - **Topic creation, deletion and expansion go to the controller.** Every
7//!   other broker answers `NOT_CONTROLLER` (41), and the controller moves on
8//!   election, so a 41 forgets the cached controller and asks again rather than
9//!   retrying the same broker forever. This is the same shape as the producer's
10//!   `NOT_COORDINATOR` handling, for the same reason.
11//! - **`DeleteRecords` goes to the partition leader**, like a produce or a
12//!   fetch. It is not a cluster operation; it moves one log's start offset.
13//!
14//! `librdkafka`'s admin surface is far larger. This is the subset that makes it
15//! possible to write a test suite and an operational tool without reaching for
16//! a second client, which is the whole reason it exists.
17
18use std::collections::BTreeMap;
19use std::time::Duration;
20
21use barnabas_core::{Disposition, ErrorCode};
22use kafka_protocol::messages::{
23    create_partitions_request::CreatePartitionsTopic,
24    create_topics_request::CreatableTopic,
25    delete_records_request::{DeleteRecordsPartition, DeleteRecordsTopic},
26    delete_topics_request::DeleteTopicState,
27    describe_configs_request::DescribeConfigsResource,
28    ApiKey, CreatePartitionsRequest, CreatePartitionsResponse, CreateTopicsRequest,
29    CreateTopicsResponse, DeleteRecordsRequest, DeleteRecordsResponse, DeleteTopicsRequest,
30    DeleteTopicsResponse, DescribeConfigsRequest, DescribeConfigsResponse, TopicName,
31};
32use kafka_protocol::protocol::StrBytes;
33
34use crate::cluster::Cluster;
35use crate::{check, Error, Result, Transport};
36
37/// Attempts for a request whose disposition says "retry" or "re-discover".
38const MAX_RETRIES: usize = 20;
39const BACKOFF: Duration = Duration::from_millis(50);
40
41/// `NOT_CONTROLLER`. Named because the whole controller-routing rule turns on
42/// it and a bare 41 in a match arm says nothing.
43const NOT_CONTROLLER: i16 = 41;
44
45/// A topic to create.
46#[derive(Debug, Clone)]
47pub struct NewTopic {
48    pub name: String,
49    pub partitions: i32,
50    pub replication_factor: i16,
51    /// Topic configuration, as the broker names it — `retention.ms`,
52    /// `cleanup.policy`, and so on.
53    pub config: BTreeMap<String, String>,
54}
55
56impl NewTopic {
57    /// A topic with broker defaults for everything but its shape.
58    #[must_use]
59    pub fn new(name: impl Into<String>, partitions: i32, replication_factor: i16) -> Self {
60        Self {
61            name: name.into(),
62            partitions,
63            replication_factor,
64            config: BTreeMap::new(),
65        }
66    }
67
68    #[must_use]
69    pub fn with_config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
70        self.config.insert(key.into(), value.into());
71        self
72    }
73}
74
75/// One broker, as the cluster describes itself.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct BrokerInfo {
78    pub node_id: i32,
79    pub host: String,
80    pub port: i32,
81    /// Whether this broker is the controller.
82    pub is_controller: bool,
83}
84
85/// Administrative operations, on one core like everything else here.
86pub struct Admin<T: Transport> {
87    cluster: Cluster<T>,
88    timeout_ms: i32,
89}
90
91impl<T: Transport> Admin<T> {
92    /// Connect to the cluster.
93    ///
94    /// # Errors
95    /// If no bootstrap address answers.
96    pub async fn connect(transport: T, bootstrap: &[String], client_id: &str) -> Result<Self> {
97        Ok(Self {
98            cluster: Cluster::connect(transport, bootstrap, client_id).await?,
99            timeout_ms: 30_000,
100        })
101    }
102
103    /// How long the **broker** may take to complete an operation before it
104    /// gives up on it. Not a client-side deadline.
105    pub fn set_operation_timeout(&mut self, timeout: Duration) {
106        self.timeout_ms = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX);
107    }
108
109    /// The underlying cluster, for callers that want metadata directly.
110    pub fn cluster(&mut self) -> &mut Cluster<T> {
111        &mut self.cluster
112    }
113
114    /// Send a request to the controller, re-discovering it on
115    /// `NOT_CONTROLLER`.
116    ///
117    /// `error_of` returns the first error the response carries; `Ok(0)` means
118    /// the whole response succeeded.
119    async fn controller_call<Req, Resp, F>(
120        &mut self,
121        op: &'static str,
122        api_key: ApiKey,
123        version: i16,
124        req: &Req,
125        error_of: F,
126    ) -> Result<Resp>
127    where
128        Req: kafka_protocol::protocol::Encodable,
129        Resp: kafka_protocol::protocol::Decodable,
130        F: Fn(&Resp) -> i16,
131    {
132        for attempt in 0..MAX_RETRIES {
133            let addr = match self.cluster.controller_addr().await {
134                Ok(addr) => addr,
135                Err(Error::Missing("a controller")) if attempt + 1 < MAX_RETRIES => {
136                    // A controller election is a wait, not a failure.
137                    T::sleep(BACKOFF).await;
138                    continue;
139                }
140                Err(e) => return Err(e),
141            };
142            let resp: Resp = self.cluster.call_at(&addr, api_key, version, req).await?;
143
144            let code = ErrorCode(error_of(&resp));
145            if code.is_ok() {
146                return Ok(resp);
147            }
148            if code.0 == NOT_CONTROLLER {
149                self.cluster.invalidate_controller();
150                T::sleep(BACKOFF).await;
151                continue;
152            }
153            if code.disposition() == Disposition::Retry && attempt + 1 < MAX_RETRIES {
154                T::sleep(BACKOFF).await;
155                continue;
156            }
157            return Err(Error::Broker {
158                op,
159                code: code.0,
160                disposition: code.disposition(),
161            });
162        }
163        Err(Error::Broker {
164            op,
165            code: NOT_CONTROLLER,
166            disposition: Disposition::Retry,
167        })
168    }
169
170    /// Create topics.
171    ///
172    /// **`TOPIC_ALREADY_EXISTS` is an error here**, not a silent success. A
173    /// caller who wants "create if absent" can say so by ignoring that code;
174    /// a caller who does not want it and never learns is the one who ends up
175    /// producing to a topic with the wrong partition count.
176    ///
177    /// # Errors
178    /// If the controller rejects any of them.
179    pub async fn create_topics(&mut self, topics: &[NewTopic]) -> Result<()> {
180        if topics.is_empty() {
181            return Ok(());
182        }
183        let mut req = CreateTopicsRequest::default();
184        req.timeout_ms = self.timeout_ms;
185        req.topics = topics
186            .iter()
187            .map(|topic| {
188                let mut entry = CreatableTopic::default();
189                entry.name = TopicName(StrBytes::from_string(topic.name.clone()));
190                entry.num_partitions = topic.partitions;
191                entry.replication_factor = topic.replication_factor;
192                entry.configs = topic
193                    .config
194                    .iter()
195                    .map(|(key, value)| {
196                        let mut config =
197                            kafka_protocol::messages::create_topics_request::CreatableTopicConfig::default();
198                        config.name = StrBytes::from_string(key.clone());
199                        config.value = Some(StrBytes::from_string(value.clone()));
200                        config
201                    })
202                    .collect();
203                entry
204            })
205            .collect();
206
207        let _: CreateTopicsResponse = self
208            .controller_call(
209                "CreateTopics",
210                ApiKey::CreateTopics,
211                5,
212                &req,
213                |r: &CreateTopicsResponse| {
214                    r.topics
215                        .iter()
216                        .map(|t| t.error_code)
217                        .find(|c| *c != 0)
218                        .unwrap_or(0)
219                },
220            )
221            .await?;
222
223        // **Return when the topics are usable, not when the controller said
224        // yes.** The two are seconds apart: a producer that writes immediately
225        // after this asks a broker that has not heard about the topic and gets
226        // "no leader", and a consumer that subscribes gets a partition count of
227        // zero. Every caller would otherwise write this loop, and the ones who
228        // forgot would have a test that fails once a fortnight.
229        for topic in topics {
230            for attempt in 0..MAX_RETRIES {
231                let _ = self.cluster.refresh_metadata(&topic.name).await;
232                if self.cluster.metadata().partition_count(&topic.name) >= topic.partitions {
233                    break;
234                }
235                if attempt + 1 == MAX_RETRIES {
236                    return Err(Error::NoLeader {
237                        topic: topic.name.clone(),
238                        partition: -1,
239                    });
240                }
241                T::sleep(BACKOFF).await;
242            }
243        }
244        Ok(())
245    }
246
247    /// Delete topics. Asynchronous on the broker: the response means the
248    /// deletion was accepted, not that the log files are gone.
249    ///
250    /// # Errors
251    /// If the controller rejects any of them.
252    pub async fn delete_topics(&mut self, names: &[String]) -> Result<()> {
253        if names.is_empty() {
254            return Ok(());
255        }
256        let mut req = DeleteTopicsRequest::default();
257        req.timeout_ms = self.timeout_ms;
258        req.topics = names
259            .iter()
260            .map(|name| {
261                let mut entry = DeleteTopicState::default();
262                entry.name = Some(TopicName(StrBytes::from_string(name.clone())));
263                entry
264            })
265            .collect();
266
267        let _: DeleteTopicsResponse = self
268            .controller_call(
269                "DeleteTopics",
270                ApiKey::DeleteTopics,
271                6,
272                &req,
273                |r: &DeleteTopicsResponse| {
274                    r.responses
275                        .iter()
276                        .map(|t| t.error_code)
277                        .find(|c| *c != 0)
278                        .unwrap_or(0)
279                },
280            )
281            .await?;
282        Ok(())
283    }
284
285    /// Grow a topic to `count` partitions **in total**, not by `count`.
286    ///
287    /// The broker's own field is named `count` and means the new total; a
288    /// wrapper that treated it as a delta would shrink a topic on the second
289    /// call, which the broker refuses — loudly, which is the only reason that
290    /// bug is survivable.
291    ///
292    /// Expanding a topic **changes where keys land** for every default
293    /// partitioner, this client's included. It is not a transparent operation.
294    ///
295    /// # Errors
296    /// If the controller rejects it.
297    pub async fn create_partitions(&mut self, topic: &str, count: i32) -> Result<()> {
298        let mut entry = CreatePartitionsTopic::default();
299        entry.name = TopicName(StrBytes::from_string(topic.to_owned()));
300        entry.count = count;
301        // `None` lets the controller choose the replicas, which is what an
302        // operator wants unless they are placing them by hand.
303        entry.assignments = None;
304
305        let mut req = CreatePartitionsRequest::default();
306        req.timeout_ms = self.timeout_ms;
307        req.validate_only = false;
308        req.topics = vec![entry];
309
310        let _: CreatePartitionsResponse = self
311            .controller_call(
312                "CreatePartitions",
313                ApiKey::CreatePartitions,
314                3,
315                &req,
316                |r: &CreatePartitionsResponse| {
317                    r.results
318                        .iter()
319                        .map(|t| t.error_code)
320                        .find(|c| *c != 0)
321                        .unwrap_or(0)
322                },
323            )
324            .await?;
325
326        // As in [`Self::create_topics`]: visible, not merely accepted.
327        for attempt in 0..MAX_RETRIES {
328            let _ = self.cluster.refresh_metadata(topic).await;
329            if self.cluster.metadata().partition_count(topic) >= count {
330                return Ok(());
331            }
332            if attempt + 1 == MAX_RETRIES {
333                return Err(Error::NoLeader {
334                    topic: topic.to_owned(),
335                    partition: -1,
336                });
337            }
338            T::sleep(BACKOFF).await;
339        }
340        Ok(())
341    }
342
343    /// Every broker in the cluster, and which one is the controller.
344    ///
345    /// # Errors
346    /// If no broker answers.
347    pub async fn describe_cluster(&mut self) -> Result<Vec<BrokerInfo>> {
348        self.cluster.refresh_cluster().await?;
349        let metadata = self.cluster.metadata();
350        let controller = metadata.controller().map(|b| b.node_id);
351        Ok(metadata
352            .brokers()
353            .map(|broker| BrokerInfo {
354                node_id: broker.node_id,
355                host: broker.host.clone(),
356                port: broker.port,
357                is_controller: controller == Some(broker.node_id),
358            })
359            .collect())
360    }
361
362    /// A topic's effective configuration: every key the broker reports,
363    /// including the ones it defaulted.
364    ///
365    /// # Errors
366    /// If the topic does not exist, or no broker answers.
367    pub async fn describe_topic_config(
368        &mut self,
369        topic: &str,
370    ) -> Result<BTreeMap<String, Option<String>>> {
371        let mut resource = DescribeConfigsResource::default();
372        // 2 is TOPIC. 4 is BROKER, which this does not expose: a broker
373        // config must be asked of *that* broker, and a wrapper that hid the
374        // routing would return one broker's answer for all of them.
375        resource.resource_type = 2;
376        resource.resource_name = StrBytes::from_string(topic.to_owned());
377        resource.configuration_keys = None;
378
379        let mut req = DescribeConfigsRequest::default();
380        req.resources = vec![resource];
381        req.include_synonyms = false;
382        req.include_documentation = false;
383
384        // **A topic that was just created is not yet on every broker.** This
385        // request goes to whichever broker answers, and the controller having
386        // accepted a `CreateTopics` does not mean the metadata has reached that
387        // one — the answer is `UNKNOWN_TOPIC_OR_PARTITION` for a topic that
388        // certainly exists. Retrying briefly is what every caller would
389        // otherwise write; after the bound a genuinely absent topic still
390        // errors, a second later.
391        for attempt in 0..MAX_RETRIES {
392            let resp: DescribeConfigsResponse = self
393                .cluster
394                .call_any(ApiKey::DescribeConfigs, 4, &req)
395                .await?;
396
397            let first = resp.results.first().ok_or(Error::Missing("a resource"))?;
398            let code = ErrorCode(first.error_code);
399            if !code.is_ok()
400                && code.disposition() == Disposition::RefreshMetadata
401                && attempt + 1 < MAX_RETRIES
402            {
403                T::sleep(BACKOFF).await;
404                continue;
405            }
406
407            let mut out = BTreeMap::new();
408            for resource in &resp.results {
409                check("DescribeConfigs", resource.error_code)?;
410                for config in &resource.configs {
411                    out.insert(
412                        config.name.to_string(),
413                        config.value.as_ref().map(ToString::to_string),
414                    );
415                }
416            }
417            return Ok(out);
418        }
419        unreachable!("the loop returns on its last attempt")
420    }
421
422    /// Delete every record **before** the given offset, per partition.
423    ///
424    /// Returns each partition's new log start offset. This is the operation
425    /// that makes `beginning_offsets` interesting: after it, offset zero is
426    /// gone and a consumer that assumes zero asks for a record the broker no
427    /// longer has.
428    ///
429    /// Goes to the **leader**, not the controller: it moves one log's start.
430    ///
431    /// # Errors
432    /// If a leader cannot be found, or rejects the deletion.
433    pub async fn delete_records(
434        &mut self,
435        before: &[(barnabas_core::group::TopicPartition, i64)],
436    ) -> Result<BTreeMap<barnabas_core::group::TopicPartition, i64>> {
437        let mut out = BTreeMap::new();
438        if before.is_empty() {
439            return Ok(out);
440        }
441
442        let mut by_leader: BTreeMap<String, Vec<(barnabas_core::group::TopicPartition, i64)>> =
443            BTreeMap::new();
444        for (tp, offset) in before {
445            let addr = self.cluster.leader_addr(&tp.topic, tp.partition).await?;
446            by_leader
447                .entry(addr)
448                .or_default()
449                .push((tp.clone(), *offset));
450        }
451
452        for (addr, group) in by_leader {
453            let mut topics: BTreeMap<String, Vec<DeleteRecordsPartition>> = BTreeMap::new();
454            for (tp, offset) in &group {
455                let mut entry = DeleteRecordsPartition::default();
456                entry.partition_index = tp.partition;
457                entry.offset = *offset;
458                topics.entry(tp.topic.clone()).or_default().push(entry);
459            }
460
461            let mut req = DeleteRecordsRequest::default();
462            req.timeout_ms = self.timeout_ms;
463            req.topics = topics
464                .into_iter()
465                .map(|(name, partitions)| {
466                    let mut topic = DeleteRecordsTopic::default();
467                    topic.name = TopicName(StrBytes::from_string(name));
468                    topic.partitions = partitions;
469                    topic
470                })
471                .collect();
472
473            let resp: DeleteRecordsResponse = self
474                .cluster
475                .call_at(&addr, ApiKey::DeleteRecords, 2, &req)
476                .await?;
477
478            for topic in &resp.topics {
479                for partition in &topic.partitions {
480                    check("DeleteRecords", partition.error_code)?;
481                    out.insert(
482                        barnabas_core::group::TopicPartition::new(
483                            topic.name.0.to_string(),
484                            partition.partition_index,
485                        ),
486                        partition.low_watermark,
487                    );
488                }
489            }
490        }
491        Ok(out)
492    }
493}