Skip to main content

krabka_raft/server/
describe_quorum.rs

1//! `DescribeQuorum` (api key 55) on the controller listener, as Kafka's
2//! `KafkaRaftClient.handleDescribeQuorumRequest` answers it.
3//!
4//! 1. A request that does not name exactly `__cluster_metadata` partition 0
5//!    gets `UNKNOWN_TOPIC_OR_PARTITION` on every partition it names
6//!    (`DescribeQuorumRequest.getPartitionLevelErrorResponse`).
7//! 2. A node that is not the leader answers `NOT_LEADER_OR_FOLLOWER` for the
8//!    metadata partition (`DescribeQuorumResponse.singletonErrorResponse`).
9//! 3. The leader answers its epoch, high watermark, voters and observers, each
10//!    replica with its log end offset, directory id and the two KIP-595
11//!    timestamps, and the voters' listeners from v2
12//!    (`RaftUtil.singletonDescribeQuorumResponse`).
13
14use {
15    crate::kraft::{
16        transport::{ObserverReplica, QuorumStateSnapshot},
17        types::NodeId,
18    },
19    krabka_protocol::{
20        owned::{
21            common::describe_quorum_response::replica_state::ReplicaState,
22            describe_quorum_request::DescribeQuorumRequest,
23            describe_quorum_response::{
24                DescribeQuorumResponse, Listener, Node, PartitionData, TopicData,
25            },
26        },
27        primitives::uuid::Uuid as WireUuid,
28    },
29    std::collections::BTreeMap,
30};
31
32const METADATA_TOPIC: &str = "__cluster_metadata";
33const UNKNOWN_TOPIC_OR_PARTITION: i16 = 3;
34const NOT_LEADER_OR_FOLLOWER: i16 = 6;
35/// Kafka's `Errors.UNKNOWN_TOPIC_OR_PARTITION.message()`.
36const UNKNOWN_TOPIC_OR_PARTITION_MESSAGE: &str = "This server does not host this topic-partition.";
37/// Kafka's `Errors.NOT_LEADER_OR_FOLLOWER.message()`.
38const NOT_LEADER_OR_FOLLOWER_MESSAGE: &str = "For requests intended only for the leader, this \
39     error indicates that the broker is not the current leader. For requests intended for any \
40     replica, this error indicates that the broker is not a replica of the topic partition.";
41
42/// The wire form of a node id, or -1 when it does not fit.
43fn node_to_wire(id: NodeId) -> i32 { i32::try_from(id.0).unwrap_or(-1) }
44
45fn not_leader_or_follower_response() -> DescribeQuorumResponse {
46    DescribeQuorumResponse {
47        topics: vec![TopicData {
48            topic_name: METADATA_TOPIC.into(),
49            partitions: vec![PartitionData {
50                partition_index: 0,
51                error_code: NOT_LEADER_OR_FOLLOWER,
52                error_message: Some(NOT_LEADER_OR_FOLLOWER_MESSAGE.into()),
53                ..Default::default()
54            }],
55            ..Default::default()
56        }],
57        // Kafka's `DescribeQuorumResponse.singletonErrorResponse` leaves
58        // the top-level message at its empty default.
59        error_message: Some(String::new()),
60        ..Default::default()
61    }
62}
63
64/// Kafka's `hasValidTopicPartition` for `DescribeQuorum`.
65fn names_only_the_metadata_partition(request: &DescribeQuorumRequest) -> bool {
66    matches!(
67        request.topics.as_slice(),
68        [topic] if topic.topic_name == METADATA_TOPIC
69            && matches!(topic.partitions.as_slice(), [partition] if partition.partition_index == 0)
70    )
71}
72
73/// One observer row: what the leader tracked of a replica that fetches
74/// without being a voter, keyed by its id and directory id.
75fn observer_state(observer: &ObserverReplica) -> ReplicaState {
76    ReplicaState {
77        replica_id: node_to_wire(observer.id),
78        replica_directory_id: WireUuid(*observer.directory_id.as_bytes()),
79        log_end_offset: observer.log_end_offset,
80        last_fetch_timestamp: observer.last_fetch_ms,
81        last_caught_up_timestamp: observer.last_caught_up_ms,
82        ..Default::default()
83    }
84}
85
86/// One voter row: the log end offset and the timestamps the leader tracks,
87/// or -1 where it tracks none.
88fn replica_state(
89    quorum: &QuorumStateSnapshot,
90    id: NodeId,
91    directory_id: uuid::Uuid,
92) -> ReplicaState {
93    let tracked = |map: &BTreeMap<NodeId, i64>| map.get(&id).copied().unwrap_or(-1);
94    ReplicaState {
95        replica_id: node_to_wire(id),
96        replica_directory_id: WireUuid(*directory_id.as_bytes()),
97        log_end_offset: tracked(&quorum.per_replica_fetch_offset),
98        last_fetch_timestamp: tracked(&quorum.per_replica_last_fetch_ms),
99        last_caught_up_timestamp: tracked(&quorum.per_replica_last_caught_up_ms),
100        ..Default::default()
101    }
102}
103
104/// Answers a decoded `DescribeQuorum` request from `quorum`, this node's
105/// consensus snapshot.
106///
107/// Shared between the controller listener (this module's own caller,
108/// `kip853::describe_quorum_response`) and the broker listener, which
109/// forwards to the active controller and answers with this same builder once
110/// it holds the leader's own snapshot (`crates/broker/src/handlers/
111/// describe_quorum.rs`) -- one implementation on both listeners (#814,
112/// #1034).
113#[must_use]
114pub fn describe_quorum(
115    request: &DescribeQuorumRequest,
116    quorum: &QuorumStateSnapshot,
117) -> DescribeQuorumResponse {
118    if !names_only_the_metadata_partition(request) {
119        let topics: Vec<TopicData> = request
120            .topics
121            .iter()
122            .map(|topic| TopicData {
123                topic_name: topic.topic_name.clone(),
124                partitions: topic
125                    .partitions
126                    .iter()
127                    .map(|partition| PartitionData {
128                        partition_index: partition.partition_index,
129                        error_code: UNKNOWN_TOPIC_OR_PARTITION,
130                        error_message: Some(UNKNOWN_TOPIC_OR_PARTITION_MESSAGE.into()),
131                        ..Default::default()
132                    })
133                    .collect(),
134                ..Default::default()
135            })
136            .collect();
137        return DescribeQuorumResponse {
138            topics,
139            // Kafka's `getPartitionLevelErrorResponse` leaves the top-level
140            // message at its empty default.
141            error_message: Some(String::new()),
142            ..Default::default()
143        };
144    }
145    let Some(leader) = quorum.leader_id.filter(|_| quorum.is_leader) else {
146        return not_leader_or_follower_response();
147    };
148    DescribeQuorumResponse {
149        topics: vec![TopicData {
150            topic_name: METADATA_TOPIC.into(),
151            partitions: vec![PartitionData {
152                partition_index: 0,
153                leader_id: node_to_wire(leader),
154                leader_epoch: i32::try_from(quorum.leader_epoch).unwrap_or(i32::MAX),
155                high_watermark: quorum.high_watermark,
156                current_voters: quorum
157                    .voters
158                    .iter()
159                    .map(|voter| replica_state(quorum, voter.id, voter.directory_id))
160                    .collect(),
161                observers: quorum.observers.iter().map(observer_state).collect(),
162                // Kafka's `RaftUtil.singletonDescribeQuorumResponse` sets
163                // neither message, so both stay at their empty default.
164                error_message: Some(String::new()),
165                ..Default::default()
166            }],
167            ..Default::default()
168        }],
169        nodes: quorum
170            .voters
171            .iter()
172            .map(|voter| Node {
173                node_id: node_to_wire(voter.id),
174                listeners: voter
175                    .endpoints
176                    .iter()
177                    .map(|endpoint| Listener {
178                        name: endpoint.name.clone(),
179                        host: endpoint.host.clone(),
180                        port: endpoint.port,
181                        ..Default::default()
182                    })
183                    .collect(),
184                ..Default::default()
185            })
186            .collect(),
187        error_message: Some(String::new()),
188        ..Default::default()
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use {
195        super::*,
196        crate::server::test_support::voter,
197        assert2::check,
198        krabka_protocol::owned::describe_quorum_request::{
199            PartitionData as RequestPartition, TopicData as RequestTopic,
200        },
201        std::collections::BTreeMap,
202    };
203
204    /// A topic name with the partition indexes a request names.
205    struct RequestedTopic<'a> {
206        name: &'a str,
207        partitions: &'a [i32],
208    }
209
210    /// Topic names, each with the partition indexes a request names.
211    type RequestedTopics<'a> = &'a [RequestedTopic<'a>];
212
213    fn request(topics: RequestedTopics<'_>) -> DescribeQuorumRequest {
214        DescribeQuorumRequest {
215            topics: topics
216                .iter()
217                .map(|topic| RequestTopic {
218                    topic_name: topic.name.into(),
219                    partitions: topic
220                        .partitions
221                        .iter()
222                        .map(|&partition_index| RequestPartition {
223                            partition_index,
224                            ..Default::default()
225                        })
226                        .collect(),
227                    ..Default::default()
228                })
229                .collect(),
230            ..Default::default()
231        }
232    }
233
234    fn endpoint(port: u16) -> krabka_metadata::VoterEndpoint {
235        krabka_metadata::VoterEndpoint {
236            name: "CONTROLLER".into(),
237            host: "controller".into(),
238            port,
239        }
240    }
241
242    /// Node 1 leading epoch 7 over voters 1 and 2, with observer 9.
243    fn leader_snapshot() -> QuorumStateSnapshot {
244        QuorumStateSnapshot {
245            leader_id: Some(NodeId(1)),
246            leader_epoch: 7,
247            high_watermark: 40,
248            last_applied_record_timestamp_ms: 700,
249            pending_record_processing_time_us_avg: 0,
250            pending_record_processing_time_us_max: 0,
251            quorum_high_watermark: 40,
252            log_end_offset: 42,
253            log_start_offset: 0,
254            voters: krabka_metadata::VoterSet::from_voters([
255                voter(1, vec![endpoint(9091)]),
256                voter(2, vec![endpoint(9092)]),
257            ]),
258            voted_directory_id: None,
259            observers: vec![ObserverReplica {
260                id: NodeId(9),
261                directory_id: uuid::Uuid::from_u128(99),
262                log_end_offset: 38,
263                last_fetch_ms: 800,
264                last_caught_up_ms: 700,
265            }],
266            per_replica_fetch_offset: BTreeMap::from([(NodeId(1), 42), (NodeId(2), 40)]),
267            per_replica_last_fetch_ms: BTreeMap::from([(NodeId(1), 1_000), (NodeId(2), 900)]),
268            per_replica_last_caught_up_ms: BTreeMap::from([(NodeId(1), 1_000), (NodeId(2), 850)]),
269            is_leader: true,
270            current_state: "leader",
271        }
272    }
273
274    fn replica(
275        id: i32,
276        directory: u128,
277        log_end_offset: i64,
278        fetch: i64,
279        caught_up: i64,
280    ) -> ReplicaState {
281        ReplicaState {
282            replica_id: id,
283            replica_directory_id: WireUuid(*uuid::Uuid::from_u128(directory).as_bytes()),
284            log_end_offset,
285            last_fetch_timestamp: fetch,
286            last_caught_up_timestamp: caught_up,
287            ..Default::default()
288        }
289    }
290
291    #[test]
292    fn the_leader_describes_every_replica() {
293        let response = describe_quorum(
294            &request(&[RequestedTopic {
295                name: METADATA_TOPIC,
296                partitions: &[0],
297            }]),
298            &leader_snapshot(),
299        );
300
301        check!(
302            response
303                == DescribeQuorumResponse {
304                    topics: vec![TopicData {
305                        topic_name: METADATA_TOPIC.into(),
306                        partitions: vec![PartitionData {
307                            partition_index: 0,
308                            leader_id: 1,
309                            leader_epoch: 7,
310                            high_watermark: 40,
311                            current_voters: vec![
312                                replica(1, 1, 42, 1_000, 1_000),
313                                replica(2, 2, 40, 900, 850),
314                            ],
315                            observers: vec![replica(9, 99, 38, 800, 700)],
316                            error_message: Some(String::new()),
317                            ..Default::default()
318                        }],
319                        ..Default::default()
320                    }],
321                    nodes: [(1, 9091), (2, 9092)]
322                        .into_iter()
323                        .map(|(node_id, port)| Node {
324                            node_id,
325                            listeners: vec![Listener {
326                                name: "CONTROLLER".into(),
327                                host: "controller".into(),
328                                port,
329                                ..Default::default()
330                            }],
331                            ..Default::default()
332                        })
333                        .collect(),
334                    error_message: Some(String::new()),
335                    ..Default::default()
336                }
337        );
338    }
339
340    /// Observers are keyed by `(id, directory id)`: one id under two directories
341    /// is two rows, each with its own offset and timestamps, and a timestamp the
342    /// leader has not recorded stays -1.
343    #[test]
344    fn one_observer_id_under_two_directories_is_two_rows() {
345        let observer = |directory: u128, offset: i64, fetch: i64, caught_up: i64| ObserverReplica {
346            id: NodeId(9),
347            directory_id: uuid::Uuid::from_u128(directory),
348            log_end_offset: offset,
349            last_fetch_ms: fetch,
350            last_caught_up_ms: caught_up,
351        };
352        let snapshot = QuorumStateSnapshot {
353            observers: vec![observer(91, 30, 500, 400), observer(92, 10, 600, -1)],
354            ..leader_snapshot()
355        };
356
357        let response = describe_quorum(
358            &request(&[RequestedTopic {
359                name: METADATA_TOPIC,
360                partitions: &[0],
361            }]),
362            &snapshot,
363        );
364
365        check!(
366            response.topics[0].partitions[0].observers
367                == vec![replica(9, 91, 30, 500, 400), replica(9, 92, 10, 600, -1)]
368        );
369    }
370
371    #[test]
372    fn a_node_that_is_not_the_leader_refuses() {
373        let follower = QuorumStateSnapshot {
374            leader_id: Some(NodeId(2)),
375            is_leader: false,
376            current_state: "follower",
377            ..leader_snapshot()
378        };
379
380        check!(
381            describe_quorum(
382                &request(&[RequestedTopic {
383                    name: METADATA_TOPIC,
384                    partitions: &[0]
385                }]),
386                &follower
387            ) == not_leader_or_follower_response()
388        );
389    }
390
391    /// A voter id too large for Kafka's `i32` node id gets -1 in its replica
392    /// row and in `Nodes`, and a voter the leader has not yet heard from gets
393    /// -1 log end offset and timestamps rather than a stale or default value.
394    #[test]
395    fn an_unmappable_or_never_fetched_voter_gets_blank_fields() {
396        let huge_id = u64::from(u32::MAX) + 1;
397        let snapshot = QuorumStateSnapshot {
398            voters: krabka_metadata::VoterSet::from_voters([
399                voter(1, vec![endpoint(9091)]),
400                voter(huge_id, vec![endpoint(9093)]),
401            ]),
402            per_replica_fetch_offset: BTreeMap::from([(NodeId(1), 42)]),
403            per_replica_last_fetch_ms: BTreeMap::from([(NodeId(1), 1_000)]),
404            per_replica_last_caught_up_ms: BTreeMap::from([(NodeId(1), 1_000)]),
405            observers: vec![],
406            ..leader_snapshot()
407        };
408
409        let response = describe_quorum(
410            &request(&[RequestedTopic {
411                name: METADATA_TOPIC,
412                partitions: &[0],
413            }]),
414            &snapshot,
415        );
416        let partition = &response.topics[0].partitions[0];
417        let unmapped = partition
418            .current_voters
419            .iter()
420            .find(|voter| voter.replica_id == -1)
421            .expect("the oversized id maps to -1");
422        check!(unmapped.log_end_offset == -1, "never fetched");
423        check!(unmapped.last_fetch_timestamp == -1);
424        check!(unmapped.last_caught_up_timestamp == -1);
425        check!(
426            response.nodes.iter().any(|node| node.node_id == -1),
427            "the same id maps to -1 in Nodes"
428        );
429    }
430
431    /// Any request that does not name exactly the metadata partition gets
432    /// `UNKNOWN_TOPIC_OR_PARTITION` on every partition it names, whether or
433    /// not this node leads.
434    #[test]
435    fn a_request_for_another_partition_gets_unknown_topic_or_partition() {
436        struct Row {
437            label: &'static str,
438            topics: RequestedTopics<'static>,
439        }
440        let rows = [
441            Row {
442                label: "another topic",
443                topics: &[RequestedTopic {
444                    name: "other",
445                    partitions: &[0],
446                }],
447            },
448            Row {
449                label: "partition 1",
450                topics: &[RequestedTopic {
451                    name: METADATA_TOPIC,
452                    partitions: &[1],
453                }],
454            },
455            Row {
456                label: "two partitions",
457                topics: &[RequestedTopic {
458                    name: METADATA_TOPIC,
459                    partitions: &[0, 1],
460                }],
461            },
462            Row {
463                label: "two topics",
464                topics: &[
465                    RequestedTopic {
466                        name: METADATA_TOPIC,
467                        partitions: &[0],
468                    },
469                    RequestedTopic {
470                        name: "other",
471                        partitions: &[0],
472                    },
473                ],
474            },
475            Row {
476                label: "no topic",
477                topics: &[],
478            },
479        ];
480        // The oracle remains separate from production response construction.
481        let expected_partition = |partition_index| PartitionData {
482            partition_index,
483            error_code: 3,
484            error_message: Some(UNKNOWN_TOPIC_OR_PARTITION_MESSAGE.into()),
485            ..Default::default()
486        };
487        for Row { label, topics } in rows {
488            let expected = DescribeQuorumResponse {
489                topics: topics
490                    .iter()
491                    .map(|topic| TopicData {
492                        topic_name: topic.name.into(),
493                        partitions: topic
494                            .partitions
495                            .iter()
496                            .map(|&index| expected_partition(index))
497                            .collect(),
498                        ..Default::default()
499                    })
500                    .collect(),
501                error_message: Some(String::new()),
502                ..Default::default()
503            };
504            check!(
505                describe_quorum(&request(topics), &leader_snapshot()) == expected,
506                "{label}"
507            );
508        }
509    }
510}