1use std::collections::BTreeSet;
6
7use krabka_protocol::{
8 owned::{
9 add_raft_voter_request::{AddRaftVoterRequest, Listener},
10 common::describe_quorum_response::replica_state::ReplicaState,
11 describe_quorum_request::{
12 DescribeQuorumRequest, PartitionData as RequestPartition, TopicData as RequestTopic,
13 },
14 describe_quorum_response,
15 remove_raft_voter_request::RemoveRaftVoterRequest,
16 },
17 primitives::uuid::Uuid as ProtoUuid,
18};
19
20use crate::{AdminClient, AdminError, NOT_CONTROLLER, kafka_error_name, retry::ControllerRetry};
21
22const CLUSTER_METADATA_TOPIC: &str = "__cluster_metadata";
23
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct RaftVoterEndpoint {
27 listener: String,
28 host: String,
29 port: u16,
30}
31
32impl RaftVoterEndpoint {
33 pub fn new(
40 listener: impl Into<String>,
41 host: impl Into<String>,
42 port: u16,
43 ) -> Result<Self, AdminError> {
44 let listener = listener.into();
45 if listener.trim() != listener {
46 return Err(AdminError::InvalidArgument(format!(
47 "listener {listener:?}: leading or trailing whitespace is not allowed"
48 )));
49 }
50 if listener.is_empty() {
51 return Err(AdminError::InvalidArgument(
52 "listener: empty string is not allowed".to_owned(),
53 ));
54 }
55 if listener.to_uppercase() != listener {
56 return Err(AdminError::InvalidArgument(format!(
57 "listener {listener:?}: string must be UPPERCASE"
58 )));
59 }
60 Ok(Self {
61 listener,
62 host: host.into(),
63 port,
64 })
65 }
66
67 #[must_use]
69 pub fn listener(&self) -> &str {
70 &self.listener
71 }
72
73 #[must_use]
75 pub fn host(&self) -> &str {
76 &self.host
77 }
78
79 #[must_use]
81 pub const fn port(&self) -> u16 {
82 self.port
83 }
84}
85
86fn add_raft_voter_request(
91 cluster_id: Option<&str>,
92 voter_id: i32,
93 voter_directory_id: uuid::Uuid,
94 endpoints: &[RaftVoterEndpoint],
95 timeout_ms: i32,
96) -> AddRaftVoterRequest {
97 let mut seen = BTreeSet::new();
98 AddRaftVoterRequest {
99 cluster_id: cluster_id.map(str::to_owned),
100 timeout_ms,
101 voter_id,
102 voter_directory_id: ProtoUuid(*voter_directory_id.as_bytes()),
103 listeners: endpoints
104 .iter()
105 .filter(|endpoint| seen.insert(*endpoint))
106 .map(|endpoint| Listener {
107 name: endpoint.listener.clone(),
108 host: endpoint.host.clone(),
109 port: endpoint.port,
110 ..Default::default()
111 })
112 .collect(),
113 ..Default::default()
114 }
115}
116
117fn remove_raft_voter_request(
120 cluster_id: Option<&str>,
121 voter_id: i32,
122 voter_directory_id: uuid::Uuid,
123) -> RemoveRaftVoterRequest {
124 RemoveRaftVoterRequest {
125 cluster_id: cluster_id.map(str::to_owned),
126 voter_id,
127 voter_directory_id: ProtoUuid(*voter_directory_id.as_bytes()),
128 ..Default::default()
129 }
130}
131
132fn voter_result(api: &'static str, code: i16, message: Option<String>) -> Result<(), AdminError> {
134 if code == 0 {
135 Ok(())
136 } else {
137 Err(broker_error(api, code, message))
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct QuorumReplica {
143 pub node_id: i32,
144 pub directory_id: uuid::Uuid,
145 pub log_end_offset: i64,
146 pub last_fetch_timestamp: i64,
147 pub last_caught_up_timestamp: i64,
148}
149
150#[derive(Debug, Clone, Default, PartialEq, Eq)]
151pub struct MetadataQuorum {
152 pub leader_id: i32,
153 pub leader_epoch: i32,
154 pub high_watermark: i64,
155 pub voters: Vec<QuorumReplica>,
156 pub observers: Vec<QuorumReplica>,
157 pub nodes: Vec<QuorumNode>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct QuorumNode {
166 pub node_id: i32,
167 pub endpoints: Vec<RaftVoterEndpoint>,
168}
169
170fn broker_error(api: &'static str, code: i16, message: Option<String>) -> AdminError {
171 AdminError::Broker {
172 api,
173 code,
174 name: kafka_error_name(code),
175 message,
176 }
177}
178
179fn replica(value: &ReplicaState) -> QuorumReplica {
180 QuorumReplica {
181 node_id: value.replica_id,
182 directory_id: uuid::Uuid::from_bytes(value.replica_directory_id.0),
183 log_end_offset: value.log_end_offset,
184 last_fetch_timestamp: value.last_fetch_timestamp,
185 last_caught_up_timestamp: value.last_caught_up_timestamp,
186 }
187}
188
189fn quorum_node(node: &describe_quorum_response::Node) -> QuorumNode {
193 QuorumNode {
194 node_id: node.node_id,
195 endpoints: node
196 .listeners
197 .iter()
198 .map(|listener| RaftVoterEndpoint {
199 listener: listener.name.clone(),
200 host: listener.host.clone(),
201 port: listener.port,
202 })
203 .collect(),
204 }
205}
206
207impl AdminClient {
208 pub(crate) async fn describe_metadata_quorum_impl(&self) -> Result<MetadataQuorum, AdminError> {
210 let response = self
211 .conn
212 .send(DescribeQuorumRequest {
213 topics: vec![RequestTopic {
214 topic_name: CLUSTER_METADATA_TOPIC.into(),
215 partitions: vec![RequestPartition {
216 partition_index: 0,
217 ..Default::default()
218 }],
219 ..Default::default()
220 }],
221 ..Default::default()
222 })
223 .await?;
224 if response.error_code != 0 {
225 return Err(broker_error(
226 "DescribeQuorum",
227 response.error_code,
228 response.error_message,
229 ));
230 }
231 let nodes = response.nodes.iter().map(quorum_node).collect();
232 let partition = response
233 .topics
234 .into_iter()
235 .find(|topic| topic.topic_name == CLUSTER_METADATA_TOPIC)
236 .and_then(|topic| {
237 topic
238 .partitions
239 .into_iter()
240 .find(|partition| partition.partition_index == 0)
241 })
242 .ok_or_else(|| {
243 AdminError::Protocol("DescribeQuorum omitted __cluster_metadata partition 0".into())
244 })?;
245 if partition.error_code != 0 {
246 return Err(broker_error(
247 "DescribeQuorum",
248 partition.error_code,
249 partition.error_message,
250 ));
251 }
252 Ok(MetadataQuorum {
253 leader_id: partition.leader_id,
254 leader_epoch: partition.leader_epoch,
255 high_watermark: partition.high_watermark,
256 voters: partition.current_voters.iter().map(replica).collect(),
257 observers: partition.observers.iter().map(replica).collect(),
258 nodes,
259 })
260 }
261
262 pub async fn add_raft_voter(
281 &mut self,
282 cluster_id: Option<&str>,
283 voter_id: i32,
284 voter_directory_id: uuid::Uuid,
285 endpoints: &[RaftVoterEndpoint],
286 ) -> Result<(), AdminError> {
287 let mut retry = ControllerRetry::new("AddRaftVoter", self.retry);
288 loop {
289 let request = add_raft_voter_request(
290 cluster_id,
291 voter_id,
292 voter_directory_id,
293 endpoints,
294 retry.remaining_millis(),
295 );
296 let response = retry.bounded(self.conn.send(request)).await?;
297 if response.error_code != NOT_CONTROLLER {
298 return voter_result("AddRaftVoter", response.error_code, response.error_message);
299 }
300 retry.after_not_controller(self).await?;
301 }
302 }
303
304 pub(crate) async fn remove_raft_voter_impl(
306 &self,
307 cluster_id: Option<&str>,
308 node_id: i32,
309 directory_id: uuid::Uuid,
310 ) -> Result<(), AdminError> {
311 let request = remove_raft_voter_request(cluster_id, node_id, directory_id);
312 let mut retry = ControllerRetry::new("RemoveRaftVoter", self.retry);
313 loop {
314 let response = retry.bounded(self.conn.send(request.clone())).await?;
315 if response.error_code != NOT_CONTROLLER {
316 return voter_result(
317 "RemoveRaftVoter",
318 response.error_code,
319 response.error_message,
320 );
321 }
322 retry.after_not_controller(self).await?;
323 }
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use std::sync::{Arc, Mutex};
330
331 use krabka_client_core::MockReply;
332 use krabka_protocol::owned::{
333 add_raft_voter_request, add_raft_voter_response::AddRaftVoterResponse,
334 remove_raft_voter_request, remove_raft_voter_response::RemoveRaftVoterResponse,
335 };
336
337 use super::*;
338 use crate::{
339 AdminClientLike as _,
340 partition_leaders::test_support::{
341 ApiVersionRange, decode_request, encode_response, fast_admin, scripted_broker,
342 },
343 };
344
345 const DIRECTORY: uuid::Uuid = uuid::Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
346
347 fn endpoint(listener: &str, host: &str, port: u16) -> RaftVoterEndpoint {
348 RaftVoterEndpoint::new(listener, host, port).expect("valid endpoint")
349 }
350
351 #[test]
352 fn endpoint_listener_must_be_trimmed_non_empty_upper_case() {
353 for (name, listener, valid) in [
354 ("upper case", "CONTROLLER", true),
355 ("upper case with digits and symbols", "CONTROLLER_2-X", true),
356 ("empty", "", false),
357 ("lower case", "controller", false),
358 ("leading whitespace", " CONTROLLER", false),
359 ("trailing whitespace", "CONTROLLER ", false),
360 ] {
361 let result = RaftVoterEndpoint::new(listener, "localhost", 9093);
362 assert2::assert!(
363 matches!(result, Err(AdminError::InvalidArgument(_))) != valid,
364 "case {name}"
365 );
366 }
367 }
368
369 #[test]
370 fn add_request_names_each_endpoint_once() {
371 let endpoints = [
372 endpoint("CONTROLLER", "c1", 9093),
373 endpoint("CONTROLLER", "c1", 9093),
374 endpoint("SSL", "c1", 9094),
375 ];
376 for (name, cluster_id, expected_cluster) in [
377 (
378 "with cluster id",
379 Some("MkU3OEVBNTcwNTJENDM2Qk"),
380 Some("MkU3OEVBNTcwNTJENDM2Qk"),
381 ),
382 ("without cluster id", None, None),
383 ] {
384 assert2::assert!(
385 add_raft_voter_request(cluster_id, 3, DIRECTORY, &endpoints, 1_000)
386 == AddRaftVoterRequest {
387 cluster_id: expected_cluster.map(str::to_owned),
388 timeout_ms: 1_000,
389 voter_id: 3,
390 voter_directory_id: ProtoUuid(*DIRECTORY.as_bytes()),
391 listeners: vec![
392 Listener {
393 name: "CONTROLLER".to_owned(),
394 host: "c1".to_owned(),
395 port: 9093,
396 ..Default::default()
397 },
398 Listener {
399 name: "SSL".to_owned(),
400 host: "c1".to_owned(),
401 port: 9094,
402 ..Default::default()
403 },
404 ],
405 ack_when_committed: true,
406 ..Default::default()
407 },
408 "case {name}"
409 );
410 }
411 }
412
413 #[test]
414 fn remove_request_sends_the_cluster_id_as_given() {
415 for (name, cluster_id) in [
416 ("with cluster id", Some("MkU3OEVBNTcwNTJENDM2Qk")),
417 ("without cluster id", None),
418 ] {
419 assert2::assert!(
420 remove_raft_voter_request(cluster_id, 3, DIRECTORY)
421 == RemoveRaftVoterRequest {
422 cluster_id: cluster_id.map(str::to_owned),
423 voter_id: 3,
424 voter_directory_id: ProtoUuid(*DIRECTORY.as_bytes()),
425 ..Default::default()
426 },
427 "case {name}"
428 );
429 }
430 }
431
432 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
437 async fn describe_metadata_quorum_reads_nodes_from_v2() {
438 use krabka_protocol::owned::{
439 common::describe_quorum_response::replica_state::ReplicaState,
440 describe_quorum_request,
441 describe_quorum_response::{
442 DescribeQuorumResponse, Listener, Node, PartitionData, TopicData,
443 },
444 };
445
446 let voter = |replica_id| ReplicaState {
447 replica_id,
448 log_end_offset: 40,
449 ..Default::default()
450 };
451 let response = DescribeQuorumResponse {
452 topics: vec![TopicData {
453 topic_name: CLUSTER_METADATA_TOPIC.into(),
454 partitions: vec![PartitionData {
455 leader_id: 1,
456 leader_epoch: 3,
457 high_watermark: 41,
458 current_voters: vec![voter(1), voter(2)],
459 observers: vec![voter(7)],
460 ..Default::default()
461 }],
462 ..Default::default()
463 }],
464 nodes: vec![Node {
465 node_id: 1,
466 listeners: vec![Listener {
467 name: "CONTROLLER".into(),
468 host: "c1.example".into(),
469 port: 9093,
470 ..Default::default()
471 }],
472 ..Default::default()
473 }],
474 ..Default::default()
475 };
476 let quorum = |nodes| MetadataQuorum {
477 leader_id: 1,
478 leader_epoch: 3,
479 high_watermark: 41,
480 voters: vec![
481 QuorumReplica {
482 node_id: 1,
483 directory_id: uuid::Uuid::nil(),
484 log_end_offset: 40,
485 last_fetch_timestamp: -1,
486 last_caught_up_timestamp: -1,
487 },
488 QuorumReplica {
489 node_id: 2,
490 directory_id: uuid::Uuid::nil(),
491 log_end_offset: 40,
492 last_fetch_timestamp: -1,
493 last_caught_up_timestamp: -1,
494 },
495 ],
496 observers: vec![QuorumReplica {
497 node_id: 7,
498 directory_id: uuid::Uuid::nil(),
499 log_end_offset: 40,
500 last_fetch_timestamp: -1,
501 last_caught_up_timestamp: -1,
502 }],
503 nodes,
504 };
505 for (name, max_version, expected) in [
506 (
507 "v2 carries nodes",
508 2,
509 quorum(vec![QuorumNode {
510 node_id: 1,
511 endpoints: vec![endpoint("CONTROLLER", "c1.example", 9093)],
512 }]),
513 ),
514 ("v1 carries none", 1, quorum(vec![])),
515 ] {
516 let answer = response.clone();
517 let broker = scripted_broker(
518 vec![
519 ApiVersionRange::builder()
520 .api_key(describe_quorum_request::API_KEY)
521 .max_version(max_version)
522 .build(),
523 ],
524 move |key, version, _, _| {
525 if key == describe_quorum_request::API_KEY {
526 MockReply::Respond(encode_response(&answer, version, true))
527 } else {
528 MockReply::Silent
529 }
530 },
531 )
532 .await;
533 let mut admin = fast_admin(broker.addr, krabka_units::secs(5)).await;
534
535 let described = admin.describe_metadata_quorum().await.expect("describes");
536
537 broker.stop();
538 assert2::assert!(described == expected, "case {name}");
539 }
540 }
541
542 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
545 async fn voter_calls_retry_not_controller_and_stop_on_other_codes() {
546 for (name, add, codes, expected) in [
547 ("add succeeds", true, vec![0], (Ok(()), 1)),
548 (
549 "add after not controller",
550 true,
551 vec![NOT_CONTROLLER, 0],
552 (Ok(()), 2),
553 ),
554 (
555 "add duplicate voter is final",
556 true,
557 vec![126],
558 (Err(("AddRaftVoter", 126, Some("dup".to_owned()))), 1),
559 ),
560 ("remove succeeds", false, vec![0], (Ok(()), 1)),
561 (
562 "remove after not controller",
563 false,
564 vec![NOT_CONTROLLER, NOT_CONTROLLER, 0],
565 (Ok(()), 3),
566 ),
567 (
568 "remove voter not found is final",
569 false,
570 vec![127],
571 (Err(("RemoveRaftVoter", 127, Some("dup".to_owned()))), 1),
572 ),
573 ] {
574 let requests = Arc::new(Mutex::new(Vec::<(i16, i32)>::new()));
575 let handler_requests = Arc::clone(&requests);
576 let broker = scripted_broker(
577 vec![
578 ApiVersionRange::builder()
579 .api_key(add_raft_voter_request::API_KEY)
580 .max_version(1)
581 .build(),
582 ApiVersionRange::builder()
583 .api_key(remove_raft_voter_request::API_KEY)
584 .build(),
585 ],
586 move |api_key, version, body, _| {
587 let mut requests = handler_requests.lock().expect("requests lock");
588 let code = codes[requests.len().min(codes.len() - 1)];
589 let message = (code != 0).then(|| "dup".to_owned());
590 let reply = match api_key {
591 add_raft_voter_request::API_KEY => {
592 let request: AddRaftVoterRequest = decode_request(body, version, true);
593 requests.push((version, request.voter_id));
594 encode_response(
595 &AddRaftVoterResponse {
596 error_code: code,
597 error_message: message,
598 ..Default::default()
599 },
600 version,
601 true,
602 )
603 }
604 remove_raft_voter_request::API_KEY => {
605 let request: RemoveRaftVoterRequest =
606 decode_request(body, version, true);
607 requests.push((version, request.voter_id));
608 encode_response(
609 &RemoveRaftVoterResponse {
610 error_code: code,
611 error_message: message,
612 ..Default::default()
613 },
614 version,
615 true,
616 )
617 }
618 _ => return MockReply::Silent,
619 };
620 drop(requests);
621 MockReply::Respond(reply)
622 },
623 )
624 .await;
625 let mut admin = fast_admin(broker.addr, krabka_units::secs(5)).await;
626
627 let result = if add {
628 admin
629 .add_raft_voter(None, 3, DIRECTORY, &[endpoint("CONTROLLER", "c3", 9093)])
630 .await
631 } else {
632 admin.remove_raft_voter(None, 3, DIRECTORY).await
633 }
634 .map_err(|error| match error {
635 AdminError::Broker {
636 api, code, message, ..
637 } => (api, code, message),
638 other => panic!("case {name}: unexpected error {other:?}"),
639 });
640
641 broker.stop();
642 let version = i16::from(add);
643 assert2::assert!(
644 (result, requests.lock().expect("requests lock").clone())
645 == (expected.0, vec![(version, 3); expected.1]),
646 "case {name}"
647 );
648 }
649 }
650}