1use std::sync::atomic::{AtomicU32, Ordering};
4
5use krabka_client_core::{
6 ClientDnsLookup, ClientDnsTimeout, ClientFrameMax, ConnectionDispatchQueueCapacity,
7 ConnectionOptions, DEFAULT_METADATA_RECOVERY_REBOOTSTRAP_TRIGGER, MetadataRecoveryStrategy,
8 security::ClientSecurity,
9};
10use krabka_units::{
11 ByteSize, Time,
12 convert::{ByteSizeExt as _, TimeExt as _},
13 millis, secs,
14};
15
16use crate::{AdminError, retry::RetryPolicy};
17
18pub const DEFAULT_ADMIN_REQUEST_TIMEOUT: Time = secs(30);
20pub const DEFAULT_API_TIMEOUT: Time = secs(60);
22pub const DEFAULT_RETRY_BACKOFF: Time = millis(100);
24pub const DEFAULT_RETRY_BACKOFF_MAX: Time = secs(1);
26pub const DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT: Time = secs(10);
28pub const DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT_MAX: Time = secs(30);
30pub const DEFAULT_ADMIN_CONNECTIONS_MAX_IDLE: Time = krabka_units::minutes(5);
32pub const DEFAULT_ENABLE_METRICS_PUSH: bool = true;
34
35const RETRY_BACKOFF_JITTER: f64 = 0.2;
37
38static ADMIN_CLIENT_ID_SEQUENCE: AtomicU32 = AtomicU32::new(1);
42
43#[derive(Clone, Debug)]
49pub struct AdminClientConfig {
50 pub client_id: Option<String>,
53 pub request_timeout: Time,
55 pub default_api_timeout: Option<Time>,
59 pub retries: u32,
62 pub retry_backoff: Time,
64 pub retry_backoff_max: Time,
66 pub socket_connection_setup_timeout: Time,
69 pub socket_connection_setup_timeout_max: Time,
72 pub connections_max_idle: Time,
75 pub dns_timeout: ClientDnsTimeout,
77 pub client_dns_lookup: ClientDnsLookup,
79 pub reconnect_backoff: Time,
81 pub reconnect_backoff_max: Time,
83 pub send_buffer: Option<ByteSize>,
85 pub receive_buffer: Option<ByteSize>,
87 pub dispatch_queue_capacity: ConnectionDispatchQueueCapacity,
89 pub frame_max: ClientFrameMax,
91 pub security: Option<ClientSecurity>,
94 pub metadata_recovery_strategy: MetadataRecoveryStrategy,
96 pub metadata_recovery_rebootstrap_trigger: Time,
98 pub enable_metrics_push: bool,
104}
105
106impl Default for AdminClientConfig {
107 fn default() -> Self {
108 Self {
109 client_id: None,
110 request_timeout: DEFAULT_ADMIN_REQUEST_TIMEOUT,
111 default_api_timeout: None,
112 retries: u32::MAX,
113 retry_backoff: DEFAULT_RETRY_BACKOFF,
114 retry_backoff_max: DEFAULT_RETRY_BACKOFF_MAX,
115 socket_connection_setup_timeout: DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT,
116 socket_connection_setup_timeout_max: DEFAULT_SOCKET_CONNECTION_SETUP_TIMEOUT_MAX,
117 connections_max_idle: DEFAULT_ADMIN_CONNECTIONS_MAX_IDLE,
118 dns_timeout: ClientDnsTimeout::default(),
119 client_dns_lookup: ClientDnsLookup::default(),
120 reconnect_backoff: krabka_client_core::DEFAULT_RECONNECT_BACKOFF,
121 reconnect_backoff_max: krabka_client_core::DEFAULT_RECONNECT_BACKOFF_MAX,
122 send_buffer: Some(krabka_client_core::DEFAULT_SEND_BUFFER),
123 receive_buffer: Some(krabka_client_core::DEFAULT_RECEIVE_BUFFER),
124 dispatch_queue_capacity: ConnectionDispatchQueueCapacity::default(),
125 frame_max: ClientFrameMax::default(),
126 security: None,
127 metadata_recovery_strategy: MetadataRecoveryStrategy::default(),
128 metadata_recovery_rebootstrap_trigger: DEFAULT_METADATA_RECOVERY_REBOOTSTRAP_TRIGGER,
129 enable_metrics_push: DEFAULT_ENABLE_METRICS_PUSH,
130 }
131 }
132}
133
134#[derive(Clone, Debug)]
136pub struct ResolvedAdminConfig {
137 pub(crate) connection: ConnectionOptions,
138 pub(crate) retry: RetryPolicy,
139 pub(crate) metadata_recovery_strategy: MetadataRecoveryStrategy,
140 pub(crate) metadata_recovery_rebootstrap_trigger: Time,
141 pub(crate) enable_metrics_push: bool,
142}
143
144impl AdminClientConfig {
145 fn validate_transport(&self) -> Result<(), AdminError> {
146 for (name, value) in [
147 ("reconnect_backoff", self.reconnect_backoff),
148 ("reconnect_backoff_max", self.reconnect_backoff_max),
149 ] {
150 if !value.secs_f64().is_finite() || value < Time::ZERO {
151 return Err(AdminError::InvalidConfig(format!(
152 "{name} must be finite and nonnegative"
153 )));
154 }
155 }
156 if self.reconnect_backoff_max < self.reconnect_backoff {
157 return Err(AdminError::InvalidConfig(
158 "reconnect_backoff_max is below reconnect_backoff".into(),
159 ));
160 }
161 for (name, value) in [
162 ("send_buffer", self.send_buffer),
163 ("receive_buffer", self.receive_buffer),
164 ] {
165 if let Some(value) = value {
166 let bytes = value.bytes_f64();
167 if !bytes.is_finite()
168 || bytes < 1.0
169 || bytes.fract() != 0.0
170 || bytes > f64::from(i32::MAX)
171 {
172 return Err(AdminError::InvalidConfig(format!(
173 "{name} must contain positive whole int32 bytes"
174 )));
175 }
176 }
177 }
178 Ok(())
179 }
180
181 pub(crate) fn resolve(self) -> Result<ResolvedAdminConfig, AdminError> {
190 for (name, value) in [
191 ("request_timeout", self.request_timeout),
192 (
193 "socket_connection_setup_timeout",
194 self.socket_connection_setup_timeout,
195 ),
196 (
197 "socket_connection_setup_timeout_max",
198 self.socket_connection_setup_timeout_max,
199 ),
200 ] {
201 if !value.secs_f64().is_finite() || value <= Time::ZERO {
202 return Err(AdminError::InvalidConfig(format!(
203 "{name} must be positive and finite"
204 )));
205 }
206 }
207 if let Some(value) = self.default_api_timeout
208 && (!value.secs_f64().is_finite() || value <= Time::ZERO)
209 {
210 return Err(AdminError::InvalidConfig(
211 "default_api_timeout must be positive and finite".to_owned(),
212 ));
213 }
214 for (name, value) in [
215 ("connections_max_idle", self.connections_max_idle),
216 ("retry_backoff", self.retry_backoff),
217 ("retry_backoff_max", self.retry_backoff_max),
218 ] {
219 if !value.secs_f64().is_finite() || value < Time::ZERO {
220 return Err(AdminError::InvalidConfig(format!(
221 "{name} must be finite and not negative"
222 )));
223 }
224 }
225 self.validate_transport()?;
226 if self.socket_connection_setup_timeout_max < self.socket_connection_setup_timeout
227 || self.retry_backoff_max < self.retry_backoff
228 {
229 return Err(AdminError::InvalidConfig(
230 "maximum delay is below initial delay".into(),
231 ));
232 }
233 let api_timeout = default_api_timeout(self.request_timeout, self.default_api_timeout)?;
234 let client_id = match self.client_id {
235 Some(id) if !id.is_empty() => id,
236 _ => generate_client_id(),
237 };
238 let connection = ConnectionOptions {
239 client_id,
240 dns_timeout: self.dns_timeout,
241 client_dns_lookup: self.client_dns_lookup,
242 reconnect_backoff: self.reconnect_backoff,
243 reconnect_backoff_max: self.reconnect_backoff_max,
244 send_buffer: self.send_buffer,
245 receive_buffer: self.receive_buffer,
246 dispatch_queue_capacity: self.dispatch_queue_capacity,
247 frame_max: self.frame_max,
248 socket_connection_setup_timeout: self.socket_connection_setup_timeout,
249 socket_connection_setup_timeout_max: self.socket_connection_setup_timeout_max,
250 connections_max_idle: self.connections_max_idle,
251 request_timeout: self.request_timeout,
252 security: self.security.map(Box::new),
253 ..ConnectionOptions::default()
254 };
255 connection.validate().map_err(AdminError::InvalidConfig)?;
256 Ok(ResolvedAdminConfig {
257 connection,
258 retry: RetryPolicy {
259 timeout: api_timeout.to_std(),
260 initial_backoff: self.retry_backoff.to_std(),
261 max_backoff: self.retry_backoff_max.to_std(),
262 jitter: RETRY_BACKOFF_JITTER,
263 max_retries: self.retries,
264 },
265 metadata_recovery_strategy: self.metadata_recovery_strategy,
266 metadata_recovery_rebootstrap_trigger: self.metadata_recovery_rebootstrap_trigger,
267 enable_metrics_push: self.enable_metrics_push,
268 })
269 }
270}
271
272fn generate_client_id() -> String {
274 format!(
275 "adminclient-{}",
276 ADMIN_CLIENT_ID_SEQUENCE.fetch_add(1, Ordering::Relaxed)
277 )
278}
279
280fn default_api_timeout(request_timeout: Time, explicit: Option<Time>) -> Result<Time, AdminError> {
284 match explicit {
285 Some(api_timeout) if api_timeout < request_timeout => Err(AdminError::InvalidConfig(
286 "default_api_timeout must be no smaller than request_timeout".to_owned(),
287 )),
288 Some(api_timeout) => Ok(api_timeout),
289 None if DEFAULT_API_TIMEOUT < request_timeout => {
290 tracing::warn!(
291 request_timeout_ms = request_timeout.millis_i64(),
292 "overriding the default of default.api.timeout.ms (60000) with the configured \
293 request timeout"
294 );
295 Ok(request_timeout)
296 }
297 None => Ok(DEFAULT_API_TIMEOUT),
298 }
299}
300
301pub fn retry_for_connection_options(options: &ConnectionOptions) -> RetryPolicy {
304 let timeout = if options.request_timeout > DEFAULT_API_TIMEOUT {
305 options.request_timeout
306 } else {
307 DEFAULT_API_TIMEOUT
308 };
309 RetryPolicy {
310 timeout: timeout.to_std(),
311 initial_backoff: DEFAULT_RETRY_BACKOFF.to_std(),
312 max_backoff: DEFAULT_RETRY_BACKOFF_MAX.to_std(),
313 jitter: RETRY_BACKOFF_JITTER,
314 max_retries: u32::MAX,
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use std::{
321 sync::{
322 Arc, Mutex,
323 atomic::{AtomicUsize, Ordering},
324 },
325 time::Duration,
326 };
327
328 use assert2::{assert, check};
329 use bytes::{Buf, BytesMut};
330 use krabka_client_core::MockBroker;
331 use krabka_protocol::{
332 Encode,
333 owned::{
334 api_versions_request,
335 api_versions_response::{ApiVersion, ApiVersionsResponse},
336 unregister_broker_request,
337 unregister_broker_response::UnregisterBrokerResponse,
338 },
339 };
340 use tokio::time::Instant;
341
342 use super::*;
343 use crate::{AdminClient, AdminClientLike as _};
344
345 #[derive(Debug, PartialEq)]
348 struct Observed {
349 client_id: String,
350 request_timeout: Time,
351 setup_timeouts: (Time, Time),
352 connections_max_idle: Time,
353 retry: RetryPolicy,
354 }
355
356 fn observe(config: AdminClientConfig) -> Result<Observed, String> {
357 config
358 .resolve()
359 .map(|resolved| Observed {
360 client_id: if resolved.connection.client_id.starts_with("adminclient-") {
362 "adminclient-<n>".to_owned()
363 } else {
364 resolved.connection.client_id
365 },
366 request_timeout: resolved.connection.request_timeout,
367 setup_timeouts: (
368 resolved.connection.socket_connection_setup_timeout,
369 resolved.connection.socket_connection_setup_timeout_max,
370 ),
371 connections_max_idle: resolved.connection.connections_max_idle,
372 retry: resolved.retry,
373 })
374 .map_err(|error| error.to_string())
375 }
376
377 fn retry(timeout: Duration) -> RetryPolicy {
378 RetryPolicy {
379 timeout,
380 initial_backoff: Duration::from_millis(100),
381 max_backoff: Duration::from_secs(1),
382 jitter: 0.2,
383 max_retries: u32::MAX,
384 }
385 }
386
387 #[test]
390 fn settings_resolve_as_kafka_admin_client_config() {
391 let defaults = || Observed {
392 client_id: "adminclient-<n>".into(),
393 request_timeout: secs(30),
394 setup_timeouts: (secs(10), secs(30)),
395 connections_max_idle: krabka_units::minutes(5),
396 retry: retry(Duration::from_mins(1)),
397 };
398 for (name, config, expected) in [
399 ("defaults", AdminClientConfig::default(), Ok(defaults())),
400 (
401 "request timeout 90 s raises the default api timeout",
402 AdminClientConfig {
403 request_timeout: secs(90),
404 ..AdminClientConfig::default()
405 },
406 Ok(Observed {
407 request_timeout: secs(90),
408 retry: retry(Duration::from_secs(90)),
409 ..defaults()
410 }),
411 ),
412 (
413 "api timeout below the request timeout is an error",
414 AdminClientConfig {
415 request_timeout: secs(90),
416 default_api_timeout: Some(secs(60)),
417 ..AdminClientConfig::default()
418 },
419 Err(
420 "invalid admin client configuration: default_api_timeout must be no smaller \
421 than request_timeout"
422 .to_owned(),
423 ),
424 ),
425 (
426 "api timeout equal to the request timeout",
427 AdminClientConfig {
428 default_api_timeout: Some(secs(30)),
429 ..AdminClientConfig::default()
430 },
431 Ok(Observed {
432 retry: retry(Duration::from_secs(30)),
433 ..defaults()
434 }),
435 ),
436 (
437 "explicit client id",
438 AdminClientConfig {
439 client_id: Some("tool-a".into()),
440 ..AdminClientConfig::default()
441 },
442 Ok(Observed {
443 client_id: "tool-a".into(),
444 ..defaults()
445 }),
446 ),
447 (
448 "empty client id is generated",
449 AdminClientConfig {
450 client_id: Some(String::new()),
451 ..AdminClientConfig::default()
452 },
453 Ok(defaults()),
454 ),
455 (
456 "retries, backoff and setup timeout",
457 AdminClientConfig {
458 retries: 3,
459 retry_backoff: millis(50),
460 retry_backoff_max: secs(2),
461 socket_connection_setup_timeout: secs(5),
462 socket_connection_setup_timeout_max: secs(20),
463 connections_max_idle: secs(60),
464 ..AdminClientConfig::default()
465 },
466 Ok(Observed {
467 setup_timeouts: (secs(5), secs(20)),
468 connections_max_idle: secs(60),
469 retry: RetryPolicy {
470 initial_backoff: Duration::from_millis(50),
471 max_backoff: Duration::from_secs(2),
472 max_retries: 3,
473 ..retry(Duration::from_mins(1))
474 },
475 ..defaults()
476 }),
477 ),
478 (
479 "zero request timeout is an error",
480 AdminClientConfig {
481 request_timeout: Time::ZERO,
482 ..AdminClientConfig::default()
483 },
484 Err(
485 "invalid admin client configuration: request_timeout must be positive and \
486 finite"
487 .to_owned(),
488 ),
489 ),
490 (
491 "infinite request timeout is an error",
492 AdminClientConfig {
493 request_timeout: Time::from_secs_f64(f64::INFINITY),
494 ..AdminClientConfig::default()
495 },
496 Err(
497 "invalid admin client configuration: request_timeout must be positive and \
498 finite"
499 .to_owned(),
500 ),
501 ),
502 (
503 "NaN api timeout is an error",
504 AdminClientConfig {
505 default_api_timeout: Some(Time::from_secs_f64(f64::NAN)),
506 ..AdminClientConfig::default()
507 },
508 Err(
509 "invalid admin client configuration: default_api_timeout must be positive \
510 and finite"
511 .to_owned(),
512 ),
513 ),
514 (
515 "NaN retry backoff is an error",
516 AdminClientConfig {
517 retry_backoff: Time::from_secs_f64(f64::NAN),
518 ..AdminClientConfig::default()
519 },
520 Err(
521 "invalid admin client configuration: retry_backoff must be finite and not \
522 negative"
523 .to_owned(),
524 ),
525 ),
526 ] {
527 check!(observe(config) == expected, "{name}");
528 }
529 }
530
531 #[test]
532 fn generated_client_ids_follow_a_process_wide_sequence() {
533 let id = || {
534 AdminClientConfig::default()
535 .resolve()
536 .expect("defaults resolve")
537 .connection
538 .client_id
539 };
540 let number = |id: String| {
541 id.strip_prefix("adminclient-")
542 .and_then(|number| number.parse::<u32>().ok())
543 .expect("adminclient-<n>")
544 };
545 let first = number(id());
546 let second = number(id());
547 assert!(second > first);
548 assert!(first >= 1);
549 }
550
551 async fn timing_out_broker(
554 requests: Arc<AtomicUsize>,
555 client_ids: Arc<Mutex<Vec<String>>>,
556 ) -> MockBroker {
557 MockBroker::start(move |api_key, version, _, request| {
558 let mut header = request;
559 let client_id_len = usize::try_from(header.get_i16()).expect("client id length");
560 client_ids
561 .lock()
562 .expect("client ids lock")
563 .push(String::from_utf8_lossy(&header[..client_id_len]).into_owned());
564 let mut body = BytesMut::new();
565 match api_key {
566 api_versions_request::API_KEY => ApiVersionsResponse {
567 api_keys: vec![
568 ApiVersion {
569 api_key: api_versions_request::API_KEY,
570 ..Default::default()
571 },
572 ApiVersion {
573 api_key: unregister_broker_request::API_KEY,
574 ..Default::default()
575 },
576 ],
577 ..Default::default()
578 }
579 .encode(&mut body, 0)
580 .unwrap(),
581 unregister_broker_request::API_KEY => {
582 requests.fetch_add(1, Ordering::SeqCst);
583 body.extend_from_slice(&[0]);
584 UnregisterBrokerResponse {
585 error_code: 7,
586 ..Default::default()
587 }
588 .encode(&mut body, version)
589 .unwrap();
590 }
591 _ => return None,
592 }
593 Some(body.to_vec())
594 })
595 .await
596 .unwrap()
597 }
598
599 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
603 async fn a_client_uses_its_client_id_call_deadline_and_retries() {
604 for (name, config, expected_requests) in [
605 (
606 "no retries",
607 AdminClientConfig {
608 client_id: Some("tool-a".into()),
609 retries: 0,
610 ..AdminClientConfig::default()
611 },
612 1..=1,
613 ),
614 (
615 "two retries",
616 AdminClientConfig {
617 client_id: Some("tool-a".into()),
618 retries: 2,
619 retry_backoff: millis(1),
620 retry_backoff_max: millis(1),
621 ..AdminClientConfig::default()
622 },
623 3..=3,
624 ),
625 (
626 "the call deadline stops the retries",
627 AdminClientConfig {
628 client_id: Some("tool-a".into()),
629 request_timeout: millis(100),
630 default_api_timeout: Some(millis(400)),
631 retry_backoff: millis(100),
632 retry_backoff_max: millis(100),
633 ..AdminClientConfig::default()
634 },
635 2..=6,
636 ),
637 ] {
638 let requests = Arc::new(AtomicUsize::new(0));
639 let client_ids = Arc::new(Mutex::new(Vec::new()));
640 let broker = timing_out_broker(Arc::clone(&requests), Arc::clone(&client_ids)).await;
641 let mut admin = AdminClient::connect_with_config(&[broker.addr.to_string()], config)
642 .await
643 .expect("admin connects");
644
645 let started = Instant::now();
646 let result = admin.unregister_broker(1).await;
647 let elapsed = started.elapsed();
648
649 broker.stop();
650 let code = match result {
651 Err(crate::AdminError::Broker { code, .. }) => Some(code),
652 _ => None,
653 };
654 let requests = requests.load(Ordering::SeqCst);
655 let client_ids = client_ids.lock().expect("client ids lock").clone();
656 check!(code == Some(7), "{name}");
657 check!(
658 expected_requests.contains(&requests),
659 "{name}: {requests} requests"
660 );
661 check!(elapsed < Duration::from_secs(5), "{name}");
662 check!(
663 client_ids.iter().all(|id| id == "tool-a") && !client_ids.is_empty(),
664 "{name}: {client_ids:?}"
665 );
666 }
667 }
668}
669
670#[cfg(test)]
671mod transport_configuration_tests {
672 use std::time::Duration;
673
674 use assert2::assert;
675 use krabka_client_core::{ClientDnsLookup, ClientFrameMax, ConnectionDispatchQueueCapacity};
676 use krabka_units::{
677 convert::{ByteSizeExt as _, TimeExt as _},
678 kibibytes, mebibytes, millis,
679 };
680
681 use super::AdminClientConfig;
682
683 #[test]
684 fn admin_resolves_transport_resources_without_losing_retry_policy() {
685 let config = AdminClientConfig {
686 client_dns_lookup: ClientDnsLookup::ResolveCanonicalBootstrapServersOnly,
687 reconnect_backoff: millis(27),
688 reconnect_backoff_max: millis(127),
689 send_buffer: None,
690 receive_buffer: Some(kibibytes(19)),
691 dispatch_queue_capacity: ConnectionDispatchQueueCapacity::new(7).expect("capacity"),
692 frame_max: ClientFrameMax::try_from(mebibytes(2)).expect("frame"),
693 retry_backoff: millis(37),
694 ..Default::default()
695 };
696 let resolved = config.resolve().expect("valid config");
697 let options = resolved.connection;
698 assert!(
699 (
700 options.client_dns_lookup,
701 options.reconnect_backoff,
702 options.reconnect_backoff_max,
703 options.send_buffer,
704 options.receive_buffer,
705 options.dispatch_queue_capacity.get(),
706 options.frame_max.size()
707 ) == (
708 ClientDnsLookup::ResolveCanonicalBootstrapServersOnly,
709 millis(27),
710 millis(127),
711 None,
712 Some(kibibytes(19)),
713 7,
714 mebibytes(2)
715 )
716 );
717 assert!(resolved.retry.initial_backoff == Duration::from_millis(37));
718 let invalid = AdminClientConfig {
719 reconnect_backoff: millis(2000),
720 ..Default::default()
721 };
722 assert!(invalid.resolve().is_err());
723 }
724
725 #[test]
726 fn admin_rejects_invalid_transport_bounds_and_socket_sizes() {
727 let invalid_backoff = AdminClientConfig {
728 reconnect_backoff: krabka_units::Time::from_secs_f64(f64::NAN),
729 ..Default::default()
730 };
731 assert!(invalid_backoff.resolve().is_err());
732
733 let fractional_socket_buffer = AdminClientConfig {
734 send_buffer: Some(krabka_units::ByteSize::from_bytes_f64(0.5)),
735 ..Default::default()
736 };
737 assert!(fractional_socket_buffer.resolve().is_err());
738
739 let inverted_setup_bounds = AdminClientConfig {
740 socket_connection_setup_timeout_max: krabka_units::millis(1),
741 ..Default::default()
742 };
743 assert!(inverted_setup_bounds.resolve().is_err());
744 }
745}