Skip to main content

kafka_meta/
cluster.rs

1//! The metadata cache and the RPC dispatcher.
2//!
3//! Everything above this crate sends requests through [`Cluster`], which knows
4//! four things the connection layer does not: which broker a request belongs
5//! to, what the cluster currently looks like, which errors mean "your view is
6//! stale", and how long to wait before trying again.
7//!
8//! The snapshot lives behind an `ArcSwap`. Readers take an `Arc` and never
9//! block, never wait on a refresh in progress, and never observe a partially
10//! updated cluster — a UI rendering a topic list while a refresh lands gets the
11//! old list or the new one, not a mixture.
12
13use std::collections::HashMap;
14use std::sync::{Arc, Mutex, Weak};
15use std::time::Duration;
16
17use arc_swap::ArcSwap;
18use kafka_conn::protocol::StrBytes;
19use kafka_conn::protocol::messages::metadata_request::MetadataRequestTopic;
20use kafka_conn::protocol::messages::{FindCoordinatorRequest, MetadataRequest, TopicName};
21use kafka_conn::{ApiKey, Connection, ConnectionConfig, Error, ErrorCode, Result, Rpc};
22
23use crate::pool::BrokerPool;
24use crate::retry::RetryPolicy;
25use crate::routing::{BrokerSelector, CoordinatorKind, Routing, routing};
26use crate::snapshot::{BrokerInfo, MetadataSnapshot, PartitionInfo, TopicId, TopicInfo};
27
28/// How to build a [`Cluster`].
29#[derive(Debug, Clone)]
30pub struct ClusterConfig {
31    /// Per-connection settings.
32    pub connection: ConnectionConfig,
33    /// Retry behaviour for routed requests.
34    pub retry: RetryPolicy,
35    /// How often the background task refreshes metadata.
36    ///
37    /// Kafka's own client default is five minutes. A UI wants fresher than
38    /// that, and metadata for a large cluster is not cheap, so thirty seconds
39    /// is the compromise — with on-demand invalidation doing the real work.
40    pub refresh_interval: Duration,
41    /// Refresh before answering when the snapshot is older than this.
42    pub max_staleness: Duration,
43}
44
45impl Default for ClusterConfig {
46    fn default() -> Self {
47        Self {
48            connection: ConnectionConfig::default(),
49            retry: RetryPolicy::default(),
50            refresh_interval: Duration::from_secs(30),
51            max_staleness: Duration::from_secs(300),
52        }
53    }
54}
55
56/// A connected Kafka cluster: metadata, routing, connections and retries.
57#[derive(Debug, Clone)]
58pub struct Cluster {
59    inner: Arc<Inner>,
60}
61
62#[derive(Debug)]
63struct Inner {
64    pool: BrokerPool,
65    config: ClusterConfig,
66    snapshot: ArcSwap<MetadataSnapshot>,
67    coordinators: Mutex<HashMap<(CoordinatorKind, String), i32>>,
68}
69
70impl Cluster {
71    /// Connect and fetch the first metadata snapshot.
72    pub async fn connect(
73        bootstrap: impl IntoIterator<Item = impl Into<String>>,
74        config: ClusterConfig,
75    ) -> Result<Self> {
76        let pool = BrokerPool::new(bootstrap, config.connection.clone(), config.retry);
77        let cluster = Cluster {
78            inner: Arc::new(Inner {
79                pool,
80                config,
81                snapshot: ArcSwap::from_pointee(MetadataSnapshot::empty()),
82                coordinators: Mutex::new(HashMap::new()),
83            }),
84        };
85        cluster.refresh().await?;
86        cluster.spawn_refresh_task();
87        Ok(cluster)
88    }
89
90    /// The current snapshot. Never blocks.
91    pub fn snapshot(&self) -> Arc<MetadataSnapshot> {
92        self.inner.snapshot.load_full()
93    }
94
95    /// The underlying connection pool.
96    pub fn pool(&self) -> &BrokerPool {
97        &self.inner.pool
98    }
99
100    /// The version a connection would send a specific request at.
101    ///
102    /// Exposed because several requests change *shape* with the version rather
103    /// than merely gaining fields — `Fetch` names its topics by string up to
104    /// v12 and by uuid from v13 — and the codec rejects a field set outside
105    /// its own range rather than ignoring it.
106    pub async fn negotiated_for<R: Rpc>(&self) -> Result<i16> {
107        self.inner.pool.any().await?.negotiated_for::<R>()
108    }
109
110    /// Fetch metadata for the whole cluster and install it.
111    pub async fn refresh(&self) -> Result<Arc<MetadataSnapshot>> {
112        let connection = self.inner.pool.any().await?;
113        let response = connection.send(all_topics_request(&connection)).await?;
114        let snapshot = Arc::new(decode_metadata(response));
115        self.install(snapshot.clone());
116        Ok(snapshot)
117    }
118
119    /// Fetch metadata for specific topics and merge it in.
120    ///
121    /// Cheaper than a full refresh by orders of magnitude on a large cluster,
122    /// and the only sane thing to do when the trigger was one partition's
123    /// leader moving.
124    pub async fn refresh_topics(&self, topics: &[&str]) -> Result<Arc<MetadataSnapshot>> {
125        if topics.is_empty() {
126            return Ok(self.snapshot());
127        }
128        let connection = self.inner.pool.any().await?;
129        let response = connection.send(topics_request(topics)).await?;
130        let fresh = decode_metadata(response);
131        let merged = Arc::new(self.snapshot().with_topics_merged(fresh.topics().to_vec()));
132        self.install(merged.clone());
133        Ok(merged)
134    }
135
136    /// Refresh only if the snapshot has gone stale.
137    pub async fn refresh_if_stale(&self) -> Result<Arc<MetadataSnapshot>> {
138        let snapshot = self.snapshot();
139        if snapshot.age() < self.inner.config.max_staleness && !snapshot.brokers().is_empty() {
140            return Ok(snapshot);
141        }
142        self.refresh().await
143    }
144
145    /// The leader of a partition, refreshing if the snapshot does not know.
146    pub async fn leader_for(&self, topic: &str, partition: i32) -> Result<i32> {
147        if let Some(leader) = self.snapshot().leader_for(topic, partition) {
148            return Ok(leader);
149        }
150        let snapshot = self.refresh_topics(&[topic]).await?;
151        snapshot.leader_for(topic, partition).ok_or_else(|| {
152            match snapshot.topic(topic).and_then(|t| t.error) {
153                Some(code) => Error::from_code(code, Some(format!("topic {topic}"))),
154                None => Error::from_code(
155                    ErrorCode::LeaderNotAvailable,
156                    Some(format!("{topic}-{partition}")),
157                ),
158            }
159        })
160    }
161
162    /// The coordinator for a group, cached.
163    pub async fn coordinator_for(&self, group: &str) -> Result<i32> {
164        self.coordinator(CoordinatorKind::Group, group).await
165    }
166
167    /// The coordinator for a group or transactional id, cached.
168    ///
169    /// Retried on the retriable codes like every other routed call. This one
170    /// is easy to miss because it is not a `send_*` and so never went through
171    /// [`Cluster::dispatch`] — but `COORDINATOR_NOT_AVAILABLE` is exactly what
172    /// a *fresh* cluster returns, because `__consumer_offsets` is created
173    /// lazily on first use and has no leader for a moment afterwards. Without
174    /// a retry the first group lookup against a new cluster is a hard error
175    /// for a condition that clears itself.
176    ///
177    /// "About a second", this used to say, and the attempt budget was sized
178    /// for that — five attempts, roughly 1.5s. On a three-node cluster with
179    /// 50 offset partitions to elect leaders for it is not about a second, so
180    /// the wait is bounded by [`RetryPolicy::coordinator_timeout`] instead.
181    ///
182    /// This loop is *not* what the KIP-848 acceptance failures were about,
183    /// though it was blamed for them first. Those never reached any retry:
184    /// they arrived as a code inside a successful response, which `kafka-consume`
185    /// re-asks for above the decode. This one covers the narrower case where
186    /// `FindCoordinator` itself is refused.
187    pub async fn coordinator(&self, kind: CoordinatorKind, key: &str) -> Result<i32> {
188        let policy = self.inner.config.retry;
189        let started = std::time::Instant::now();
190        let mut attempt = 1;
191        loop {
192            let delay = policy.delay(attempt);
193            if !delay.is_zero() {
194                tokio::time::sleep(delay).await;
195            }
196
197            let error = match self.coordinator_once(kind, key).await {
198                Ok(node) => return Ok(node),
199                Err(error) => error,
200            };
201
202            let budget_left = if error.needs_coordinator_refresh() {
203                started.elapsed() < policy.coordinator_timeout
204            } else {
205                policy.should_retry(attempt)
206            };
207
208            if !error.retriable() || !budget_left {
209                return Err(error);
210            }
211            tracing::debug!(?kind, key, attempt, %error, "retrying FindCoordinator");
212            attempt = attempt.saturating_add(1);
213        }
214    }
215
216    /// One `FindCoordinator` round trip, cache included.
217    async fn coordinator_once(&self, kind: CoordinatorKind, key: &str) -> Result<i32> {
218        let cache_key = (kind, key.to_owned());
219        if let Some(node) = self
220            .inner
221            .coordinators
222            .lock()
223            .ok()
224            .and_then(|map| map.get(&cache_key).copied())
225        {
226            return Ok(node);
227        }
228
229        let connection = self.inner.pool.any().await?;
230        // `key` is versions 0-3 and `coordinator_keys` is 4+, and the codec
231        // *rejects* a field set outside its own version range rather than
232        // ignoring it. Setting both to cover the range looks like belt and
233        // braces and is an encode failure on every modern broker — which takes
234        // down every coordinator-routed RPC with it.
235        let version = connection.negotiated_for::<FindCoordinatorRequest>()?;
236        let request = FindCoordinatorRequest::default().with_key_type(kind.key_type());
237        let request = if version >= 4 {
238            request.with_coordinator_keys(vec![StrBytes::from_string(key.to_owned())])
239        } else {
240            request.with_key(StrBytes::from_string(key.to_owned()))
241        };
242        let response = connection.send(request).await?;
243
244        // v4+ moved the answer into a `coordinators` array and left the
245        // top-level fields empty; older versions do the opposite. Reading only
246        // one of the two shapes yields "coordinator 0", which is a real broker
247        // id and therefore a bug that looks like it works.
248        let (node_id, error_code, message) = match response.coordinators.first() {
249            Some(coordinator) => (
250                coordinator.node_id.0,
251                coordinator.error_code,
252                coordinator.error_message.as_ref().map(|m| m.to_string()),
253            ),
254            None => (
255                response.node_id.0,
256                response.error_code,
257                response.error_message.as_ref().map(|m| m.to_string()),
258            ),
259        };
260
261        if let Some(code) = ErrorCode::from_code(error_code) {
262            return Err(Error::from_code(code, message));
263        }
264        if node_id < 0 {
265            return Err(Error::from_code(
266                ErrorCode::CoordinatorNotAvailable,
267                Some(key.to_owned()),
268            ));
269        }
270
271        if let Ok(mut map) = self.inner.coordinators.lock() {
272            map.insert(cache_key, node_id);
273        }
274        Ok(node_id)
275    }
276
277    /// The active controller.
278    pub async fn controller(&self) -> Result<i32> {
279        if let Some(id) = self.snapshot().controller_id() {
280            return Ok(id);
281        }
282        self.refresh()
283            .await?
284            .controller_id()
285            .ok_or_else(|| Error::from_code(ErrorCode::NotController, None))
286    }
287
288    /// Forget a cached coordinator.
289    pub fn invalidate_coordinator(&self, kind: CoordinatorKind, key: &str) {
290        if let Ok(mut map) = self.inner.coordinators.lock() {
291            map.remove(&(kind, key.to_owned()));
292        }
293    }
294
295    /// Discard the snapshot, forcing the next access to refetch.
296    pub fn invalidate(&self) {
297        self.install(Arc::new(MetadataSnapshot::empty()));
298    }
299
300    /// Send a request to any broker.
301    pub async fn send_any<R: Rpc + Clone>(&self, request: R) -> Result<R::Response> {
302        self.dispatch(Target::Any, request).await
303    }
304
305    /// Send a request to the controller.
306    pub async fn send_to_controller<R: Rpc + Clone>(&self, request: R) -> Result<R::Response> {
307        self.dispatch(Target::Controller, request).await
308    }
309
310    /// Send a request to one named broker.
311    pub async fn send_to_node<R: Rpc + Clone>(
312        &self,
313        node_id: i32,
314        request: R,
315    ) -> Result<R::Response> {
316        self.dispatch(Target::Node(node_id), request).await
317    }
318
319    /// Send a request to a group or transaction coordinator.
320    pub async fn send_to_coordinator<R: Rpc + Clone>(
321        &self,
322        kind: CoordinatorKind,
323        key: &str,
324        request: R,
325    ) -> Result<R::Response> {
326        self.dispatch(Target::Coordinator(kind, key.to_owned()), request)
327            .await
328    }
329
330    /// Send a request to a partition's leader.
331    pub async fn send_to_leader<R: Rpc + Clone>(
332        &self,
333        topic: &str,
334        partition: i32,
335        request: R,
336    ) -> Result<R::Response> {
337        self.dispatch(Target::Leader(topic.to_owned(), partition), request)
338            .await
339    }
340
341    /// Send a request to wherever [`routing`] says it belongs.
342    ///
343    /// Only usable for the `Any` and `Controller` classes; coordinator- and
344    /// broker-routed requests need a key the api key alone does not carry, so
345    /// asking for them here is a caller error rather than a guess.
346    pub async fn send_routed<R: Rpc + Clone>(&self, request: R) -> Result<R::Response> {
347        match routing(R::API_KEY) {
348            Routing::Any => self.send_any(request).await,
349            Routing::Controller => self.send_to_controller(request).await,
350            Routing::Coordinator(kind) => Err(Error::InvalidRequest(format!(
351                "{} is routed to a {kind:?} coordinator; use send_to_coordinator",
352                R::API_KEY
353            ))),
354            Routing::Specific(BrokerSelector::Caller) => Err(Error::InvalidRequest(format!(
355                "{} is routed to one broker; use send_to_node",
356                R::API_KEY
357            ))),
358            Routing::Specific(BrokerSelector::PartitionLeader) => {
359                Err(Error::InvalidRequest(format!(
360                    "{} is routed to a partition leader; use send_to_leader",
361                    R::API_KEY
362                )))
363            }
364        }
365    }
366
367    /// The retry loop: resolve a broker, send, and decide what a failure means.
368    async fn dispatch<R: Rpc + Clone>(&self, target: Target, request: R) -> Result<R::Response> {
369        let policy = self.inner.config.retry;
370        let started = std::time::Instant::now();
371        let mut attempt = 1;
372        loop {
373            let delay = policy.delay(attempt);
374            if !delay.is_zero() {
375                tokio::time::sleep(delay).await;
376            }
377
378            let outcome = self.attempt(&target, request.clone()).await;
379            let error = match outcome {
380                Ok(response) => return Ok(response),
381                Err(error) => error,
382            };
383
384            // Two independent axes, and both have to be acted on: a stale
385            // leader and a moved coordinator are different caches, and
386            // refreshing the wrong one leaves the retry pointed at the same
387            // wrong broker.
388            if error.needs_metadata_refresh() {
389                self.on_stale_metadata(&target).await;
390            }
391            let coordinator_moved =
392                error.needs_coordinator_refresh() && matches!(&target, Target::Coordinator(..));
393            if coordinator_moved && let Target::Coordinator(kind, key) = &target {
394                self.invalidate_coordinator(*kind, key);
395            }
396
397            // A moved or still-loading coordinator is bounded by time, not by
398            // attempts. The attempt count is tuned for "this broker answered
399            // badly"; a coordinator handover is a cluster-side event whose
400            // duration has nothing to do with our backoff curve, and five
401            // attempts expire ~1.5s into an election that routinely takes
402            // longer. See `RetryPolicy::coordinator_timeout`.
403            let budget_left = if coordinator_moved {
404                started.elapsed() < policy.coordinator_timeout
405            } else {
406                policy.should_retry(attempt)
407            };
408
409            if !error.retriable() || !budget_left {
410                return Err(error);
411            }
412            tracing::debug!(api = %R::API_KEY, attempt, %error, "retrying");
413            attempt = attempt.saturating_add(1);
414        }
415    }
416
417    async fn attempt<R: Rpc + Clone>(&self, target: &Target, request: R) -> Result<R::Response> {
418        let connection = self.resolve(target).await?;
419        connection.send(request).await
420    }
421
422    async fn resolve(&self, target: &Target) -> Result<Connection> {
423        match target {
424            Target::Any => self.inner.pool.any().await,
425            Target::Node(node_id) => self.inner.pool.get(*node_id).await,
426            Target::Controller => {
427                let controller = self.controller().await?;
428                self.inner.pool.get(controller).await
429            }
430            Target::Coordinator(kind, key) => {
431                let node = self.coordinator(*kind, key).await?;
432                self.inner.pool.get(node).await
433            }
434            Target::Leader(topic, partition) => {
435                let leader = self.leader_for(topic, *partition).await?;
436                self.inner.pool.get(leader).await
437            }
438        }
439    }
440
441    async fn on_stale_metadata(&self, target: &Target) {
442        let refreshed = match target {
443            Target::Leader(topic, _) => self.refresh_topics(&[topic.as_str()]).await.map(|_| ()),
444            _ => self.refresh().await.map(|_| ()),
445        };
446        if let Err(error) = refreshed {
447            tracing::debug!(%error, "metadata refresh after a stale-view error failed");
448        }
449    }
450
451    fn install(&self, snapshot: Arc<MetadataSnapshot>) {
452        self.inner.pool.learn_addresses(
453            snapshot
454                .brokers()
455                .iter()
456                .map(|broker| (broker.node_id, broker.address())),
457        );
458        self.inner.snapshot.store(snapshot);
459    }
460
461    /// Refresh in the background, and stop when the last `Cluster` is dropped.
462    fn spawn_refresh_task(&self) {
463        let weak = Arc::downgrade(&self.inner);
464        let interval = self.inner.config.refresh_interval;
465        tokio::spawn(async move {
466            loop {
467                tokio::time::sleep(interval).await;
468                let Some(inner) = Weak::upgrade(&weak) else {
469                    return;
470                };
471                let cluster = Cluster { inner };
472                if let Err(error) = cluster.refresh().await {
473                    tracing::debug!(%error, "background metadata refresh failed");
474                }
475            }
476        });
477    }
478}
479
480/// Where a request is going.
481#[derive(Debug, Clone)]
482enum Target {
483    Any,
484    Controller,
485    Node(i32),
486    Coordinator(CoordinatorKind, String),
487    Leader(String, i32),
488}
489
490/// A metadata request for every topic.
491///
492/// The null-versus-empty distinction is version dependent: from v1 a null topic
493/// list means "everything", while at v0 an *empty* list meant that. Getting it
494/// backwards asks a modern broker for no topics at all and yields a snapshot
495/// that quietly has none.
496fn all_topics_request(connection: &Connection) -> MetadataRequest {
497    let version = connection.negotiated_version(ApiKey::Metadata).unwrap_or(1);
498    let topics = if version >= 1 { None } else { Some(Vec::new()) };
499    base_metadata_request().with_topics(topics)
500}
501
502fn topics_request(topics: &[&str]) -> MetadataRequest {
503    base_metadata_request().with_topics(Some(
504        topics
505            .iter()
506            .map(|name| {
507                MetadataRequestTopic::default()
508                    .with_name(Some(TopicName(StrBytes::from_string((*name).to_owned()))))
509            })
510            .collect(),
511    ))
512}
513
514/// Every metadata request in this workspace goes through here.
515///
516/// `MetadataRequest::default()` sets `allow_auto_topic_creation: true`, because
517/// that is the schema default and the crate honours it. On a cluster with
518/// `auto.create.topics.enable=true` that turns a typo in a UI search box into a
519/// created topic. There is no legitimate case for `true` in this codebase, so
520/// the only constructor turns it off and there is a unit test to keep it that
521/// way.
522fn base_metadata_request() -> MetadataRequest {
523    MetadataRequest::default().with_allow_auto_topic_creation(false)
524}
525
526/// Convert a metadata response into our own types.
527fn decode_metadata(response: kafka_conn::protocol::messages::MetadataResponse) -> MetadataSnapshot {
528    let brokers = response
529        .brokers
530        .into_iter()
531        .map(|broker| BrokerInfo {
532            node_id: broker.node_id.0,
533            host: broker.host.to_string(),
534            port: broker.port,
535            rack: broker.rack.map(|r| r.to_string()),
536        })
537        .collect();
538
539    let topics = response
540        .topics
541        .into_iter()
542        .map(|topic| TopicInfo {
543            name: topic.name.map(|n| n.0.to_string()).unwrap_or_default(),
544            topic_id: TopicId::from_bytes(topic.topic_id.into_bytes()),
545            internal: topic.is_internal,
546            partitions: topic
547                .partitions
548                .into_iter()
549                .map(|partition| PartitionInfo {
550                    partition: partition.partition_index,
551                    // -1 is the protocol's "no leader"; keep that out of the
552                    // domain type entirely.
553                    leader: Some(partition.leader_id.0).filter(|id| *id >= 0),
554                    leader_epoch: partition.leader_epoch,
555                    replicas: partition.replica_nodes.iter().map(|id| id.0).collect(),
556                    isr: partition.isr_nodes.iter().map(|id| id.0).collect(),
557                    offline_replicas: partition.offline_replicas.iter().map(|id| id.0).collect(),
558                    error: ErrorCode::from_code(partition.error_code),
559                })
560                .collect(),
561            error: ErrorCode::from_code(topic.error_code),
562        })
563        .collect();
564
565    MetadataSnapshot::new(
566        brokers,
567        topics,
568        Some(response.controller_id.0).filter(|id| *id >= 0),
569        response.cluster_id.map(|id| id.to_string()),
570    )
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    /// M4 makes this a required assertion, and it is worth saying why: this is
578    /// a one-word regression with a destructive blast radius, and nothing about
579    /// the resulting behaviour looks wrong from the client side.
580    #[test]
581    fn metadata_requests_never_allow_auto_topic_creation() {
582        assert!(!base_metadata_request().allow_auto_topic_creation);
583        assert!(!topics_request(&["orders"]).allow_auto_topic_creation);
584    }
585
586    #[test]
587    fn the_crates_default_is_the_dangerous_one() {
588        // If this ever starts failing, the trap has been fixed upstream and
589        // the guard above can relax. Until then it is load-bearing.
590        assert!(MetadataRequest::default().allow_auto_topic_creation);
591    }
592
593    #[test]
594    fn a_targeted_request_names_its_topics() {
595        let request = topics_request(&["orders", "events"]);
596        let names: Vec<String> = request
597            .topics
598            .unwrap_or_default()
599            .into_iter()
600            .filter_map(|t| t.name.map(|n| n.0.to_string()))
601            .collect();
602        assert_eq!(names, vec!["orders".to_owned(), "events".to_owned()]);
603    }
604
605    #[test]
606    fn send_routed_refuses_the_classes_it_cannot_resolve() {
607        // Compile-time proof that the routing table is consulted; the runtime
608        // check is exercised in the integration suite.
609        assert_eq!(
610            routing(ApiKey::OffsetFetch),
611            Routing::Coordinator(CoordinatorKind::Group)
612        );
613        assert_eq!(
614            routing(ApiKey::DescribeLogDirs),
615            Routing::Specific(BrokerSelector::Caller)
616        );
617    }
618}