Skip to main content

barnabas_core/
metadata.rs

1//! The cluster map: which broker leads which partition, and how to reach it.
2//!
3//! Sans-io like the rest of the core — it is fed a decoded `MetadataResponse`
4//! and answers questions about it. Refreshing is the binding's job, because
5//! refreshing means sending a request.
6//!
7//! # Staleness is the interesting part
8//!
9//! A metadata cache is easy; knowing when it is wrong is not. Leadership moves
10//! whenever a broker restarts or a partition is reassigned, and the client
11//! learns about it *from the error it gets for using the old leader*, not from
12//! a notification. So the flow is always: use the cache, get
13//! `NOT_LEADER_OR_FOLLOWER`, invalidate, refresh, retry — which is why
14//! [`Disposition::RefreshMetadata`](crate::Disposition::RefreshMetadata) is its
15//! own answer rather than a plain retry.
16//!
17//! Invalidation is deliberately *per partition*: a single moved partition does
18//! not make the rest of the map wrong, and throwing it all away turns one
19//! failover into a reconnect storm against every broker.
20
21use std::collections::HashMap;
22
23use kafka_protocol::messages::MetadataResponse;
24
25/// How to reach a broker.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct BrokerAddr {
28    pub node_id: i32,
29    pub host: String,
30    pub port: i32,
31}
32
33impl BrokerAddr {
34    /// `host:port`, ready for a connect.
35    #[must_use]
36    pub fn addr(&self) -> String {
37        format!("{}:{}", self.host, self.port)
38    }
39}
40
41/// A partition, named the way callers name it.
42pub type PartitionKey = (String, i32);
43
44/// What the client knows about the cluster.
45#[derive(Debug, Default)]
46pub struct Metadata {
47    brokers: HashMap<i32, BrokerAddr>,
48    leaders: HashMap<PartitionKey, i32>,
49    /// How many partitions each topic has, as the **response** said — not as
50    /// counted from known leaders.
51    ///
52    /// The two differ exactly when they matter. A `MetadataResponse` lists
53    /// every partition of a topic whether or not each has a leader right now,
54    /// so counting the response is the true count even mid-election, while
55    /// counting leaders undercounts. Expansion detection compares this number
56    /// across refreshes, and a count that dips during an election would look
57    /// like a topic that shrank — which Kafka never does.
58    partition_counts: HashMap<String, i32>,
59    /// The node id of the controller, if the last response named one.
60    ///
61    /// Only the controller serves topic creation and deletion; every other
62    /// broker answers `NOT_CONTROLLER`. It moves on election, so it is stored
63    /// as an id and resolved through `brokers` at the moment of use.
64    controller: Option<i32>,
65}
66
67impl Metadata {
68    #[must_use]
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Merge a `MetadataResponse` in.
74    ///
75    /// Merge rather than replace: a response for one topic says nothing about
76    /// the others, and replacing would silently drop leadership the client is
77    /// actively using. Partitions carrying an error code are **skipped**, not
78    /// recorded as leaderless — an error means "ask again", and writing it down
79    /// would cache the failure.
80    pub fn update(&mut self, resp: &MetadataResponse) {
81        // -1 is "no controller known", which is not the same as node -1.
82        if resp.controller_id.0 >= 0 {
83            self.controller = Some(resp.controller_id.0);
84        }
85        for broker in &resp.brokers {
86            self.brokers.insert(
87                broker.node_id.0,
88                BrokerAddr {
89                    node_id: broker.node_id.0,
90                    host: broker.host.to_string(),
91                    port: broker.port,
92                },
93            );
94        }
95        for topic in &resp.topics {
96            if topic.error_code != 0 {
97                continue;
98            }
99            let Some(name) = topic.name.as_ref() else {
100                continue;
101            };
102            self.partition_counts.insert(
103                name.0.to_string(),
104                i32::try_from(topic.partitions.len()).unwrap_or(i32::MAX),
105            );
106            for partition in &topic.partitions {
107                if partition.error_code != 0 || partition.leader_id.0 < 0 {
108                    continue;
109                }
110                self.leaders.insert(
111                    (name.0.to_string(), partition.partition_index),
112                    partition.leader_id.0,
113                );
114            }
115        }
116    }
117
118    /// Where to send a request for this partition, if known.
119    #[must_use]
120    pub fn leader_for(&self, topic: &str, partition: i32) -> Option<&BrokerAddr> {
121        let node = self.leaders.get(&(topic.to_owned(), partition))?;
122        self.brokers.get(node)
123    }
124
125    /// Look a broker up by node id — how a coordinator, which is named by id
126    /// rather than address, is reached.
127    #[must_use]
128    pub fn broker(&self, node_id: i32) -> Option<&BrokerAddr> {
129        self.brokers.get(&node_id)
130    }
131
132    /// The controller, if one has been named and its address is known.
133    #[must_use]
134    pub fn controller(&self) -> Option<&BrokerAddr> {
135        self.brokers.get(&self.controller?)
136    }
137
138    /// Forget which broker is the controller, after it said it is not.
139    pub fn invalidate_controller(&mut self) {
140        self.controller = None;
141    }
142
143    /// Forget one partition's leader, leaving the rest of the map alone.
144    pub fn invalidate_partition(&mut self, topic: &str, partition: i32) {
145        self.leaders.remove(&(topic.to_owned(), partition));
146    }
147
148    /// How many partitions this topic has, or 0 if it has never been seen.
149    ///
150    /// Taken from the last response that named the topic, so it is stable
151    /// across elections: a partition without a leader still exists, and a count
152    /// that dipped while one was being elected would silently move every key.
153    #[must_use]
154    pub fn partition_count(&self, topic: &str) -> i32 {
155        self.partition_counts.get(topic).copied().unwrap_or(0)
156    }
157
158    /// Forget a topic entirely — its count and every leader.
159    ///
160    /// For a topic that was deleted. Deletion is the only way a partition
161    /// count goes down, and it goes down by going away.
162    pub fn forget_topic(&mut self, topic: &str) {
163        self.partition_counts.remove(topic);
164        self.leaders.retain(|(name, _), _| name != topic);
165    }
166
167    /// Every broker the client has heard of, for connection cleanup.
168    pub fn brokers(&self) -> impl Iterator<Item = &BrokerAddr> {
169        self.brokers.values()
170    }
171
172    #[must_use]
173    pub fn is_empty(&self) -> bool {
174        self.brokers.is_empty()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use kafka_protocol::messages::metadata_response::{
182        MetadataResponseBroker, MetadataResponsePartition, MetadataResponseTopic,
183    };
184    use kafka_protocol::messages::{BrokerId, TopicName};
185    use kafka_protocol::protocol::StrBytes;
186
187    fn broker(node_id: i32, host: &str, port: i32) -> MetadataResponseBroker {
188        let mut b = MetadataResponseBroker::default();
189        b.node_id = BrokerId(node_id);
190        b.host = StrBytes::from_string(host.to_owned());
191        b.port = port;
192        b
193    }
194
195    fn topic(
196        name: &str,
197        error_code: i16,
198        partitions: Vec<MetadataResponsePartition>,
199    ) -> MetadataResponseTopic {
200        let mut t = MetadataResponseTopic::default();
201        t.name = Some(TopicName(StrBytes::from_string(name.to_owned())));
202        t.error_code = error_code;
203        t.partitions = partitions;
204        t
205    }
206
207    fn partition(index: i32, leader: i32, error_code: i16) -> MetadataResponsePartition {
208        let mut p = MetadataResponsePartition::default();
209        p.partition_index = index;
210        p.leader_id = BrokerId(leader);
211        p.error_code = error_code;
212        p
213    }
214
215    fn response(
216        brokers: Vec<MetadataResponseBroker>,
217        topics: Vec<MetadataResponseTopic>,
218    ) -> MetadataResponse {
219        let mut r = MetadataResponse::default();
220        r.brokers = brokers;
221        r.topics = topics;
222        r
223    }
224
225    #[test]
226    fn a_leader_is_resolved_to_an_address() {
227        let mut md = Metadata::new();
228        md.update(&response(
229            vec![broker(1, "kafka-1", 9092), broker(2, "kafka-2", 9092)],
230            vec![topic("t", 0, vec![partition(0, 2, 0)])],
231        ));
232        assert_eq!(md.leader_for("t", 0).unwrap().addr(), "kafka-2:9092");
233        assert!(md.leader_for("t", 1).is_none());
234        assert!(md.leader_for("other", 0).is_none());
235    }
236
237    /// **Merge, not replace.** A response for one topic must not erase what the
238    /// client knows about another it is actively fetching from.
239    #[test]
240    fn an_update_for_one_topic_leaves_the_others_alone() {
241        let mut md = Metadata::new();
242        md.update(&response(
243            vec![broker(1, "kafka-1", 9092)],
244            vec![topic("a", 0, vec![partition(0, 1, 0)])],
245        ));
246        md.update(&response(
247            vec![broker(1, "kafka-1", 9092)],
248            vec![topic("b", 0, vec![partition(0, 1, 0)])],
249        ));
250        assert!(md.leader_for("a", 0).is_some(), "topic a was forgotten");
251        assert!(md.leader_for("b", 0).is_some());
252    }
253
254    /// Leadership moved. The new response wins.
255    #[test]
256    fn a_moved_leader_is_picked_up() {
257        let mut md = Metadata::new();
258        md.update(&response(
259            vec![broker(1, "kafka-1", 9092), broker(2, "kafka-2", 9092)],
260            vec![topic("t", 0, vec![partition(0, 1, 0)])],
261        ));
262        assert_eq!(md.leader_for("t", 0).unwrap().node_id, 1);
263
264        md.update(&response(
265            vec![broker(1, "kafka-1", 9092), broker(2, "kafka-2", 9092)],
266            vec![topic("t", 0, vec![partition(0, 2, 0)])],
267        ));
268        assert_eq!(md.leader_for("t", 0).unwrap().node_id, 2);
269    }
270
271    /// An error is "ask again", not "there is no leader". Recording it would
272    /// cache a transient failure as a fact.
273    #[test]
274    fn an_errored_partition_is_not_recorded() {
275        let mut md = Metadata::new();
276        md.update(&response(
277            vec![broker(1, "kafka-1", 9092)],
278            vec![topic("t", 0, vec![partition(0, 1, 9)])],
279        ));
280        assert!(md.leader_for("t", 0).is_none());
281    }
282
283    /// A partition mid-election has leader -1, which is not an address.
284    #[test]
285    fn a_leaderless_partition_is_not_recorded() {
286        let mut md = Metadata::new();
287        md.update(&response(
288            vec![broker(1, "kafka-1", 9092)],
289            vec![topic("t", 0, vec![partition(0, -1, 0)])],
290        ));
291        assert!(md.leader_for("t", 0).is_none());
292    }
293
294    /// An errored topic contributes nothing, but its brokers still count — the
295    /// client will need them to ask again.
296    #[test]
297    fn an_errored_topic_still_yields_its_brokers() {
298        let mut md = Metadata::new();
299        md.update(&response(
300            vec![broker(7, "kafka-7", 9092)],
301            vec![topic("t", 3, vec![partition(0, 7, 0)])],
302        ));
303        assert!(md.leader_for("t", 0).is_none());
304        assert_eq!(md.broker(7).unwrap().addr(), "kafka-7:9092");
305    }
306
307    /// The partition count follows what the last refresh knew.
308    #[test]
309    fn the_partition_count_follows_metadata() {
310        let mut md = Metadata::new();
311        assert_eq!(md.partition_count("t"), 0);
312        md.update(&response(
313            vec![broker(1, "kafka-1", 9092)],
314            vec![topic(
315                "t",
316                0,
317                vec![partition(0, 1, 0), partition(1, 1, 0), partition(2, 1, 0)],
318            )],
319        ));
320        assert_eq!(md.partition_count("t"), 3);
321        assert_eq!(md.partition_count("other"), 0);
322    }
323
324    /// **Invalidation is per partition.** One moved partition must not throw
325    /// away the whole map: that turns a single failover into a reconnect storm.
326    #[test]
327    fn invalidating_one_partition_keeps_the_others() {
328        let mut md = Metadata::new();
329        md.update(&response(
330            vec![broker(1, "kafka-1", 9092)],
331            vec![topic("t", 0, vec![partition(0, 1, 0), partition(1, 1, 0)])],
332        ));
333        md.invalidate_partition("t", 0);
334        assert!(md.leader_for("t", 0).is_none());
335        assert!(md.leader_for("t", 1).is_some());
336        assert!(
337            md.broker(1).is_some(),
338            "the broker itself is still reachable and its connection stays open"
339        );
340    }
341}