1use std::{collections::BTreeMap, time::Duration};
5
6use krabka_protocol::owned::{
7 api_versions_request::ApiVersionsRequest,
8 api_versions_response::ApiVersionsResponse,
9 update_features_request::{FeatureUpdateKey, UpdateFeaturesRequest},
10 update_features_response::UpdateFeaturesResponse,
11};
12use krabka_units::{Time, convert::TimeExt as _};
13
14use crate::{
15 AdminClient, AdminError, KafkaError, MetadataVersionUpdate, NOT_CONTROLLER, kafka_error_if,
16 kafka_error_name,
17 log_dirs::{NodeTarget, call_node},
18 retry::{ControllerRetry, CoordinatorRetry, RetryPolicy},
19};
20
21const METADATA_VERSION_FEATURE: &str = "metadata.version";
22
23const UNKNOWN_SERVER_ERROR: i16 = -1;
25
26const FEATURES_API_VERSIONS_MIN_VERSION: i16 = 3;
28
29const UPGRADE_TYPE_MIN_VERSION: i16 = 1;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct FeatureRange {
36 pub name: String,
37 pub min_version: i16,
38 pub max_version: i16,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct FeatureMetadata {
44 pub supported: Vec<FeatureRange>,
45 pub finalized: Vec<FeatureRange>,
46 pub finalized_features_epoch: Option<i64>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum UpgradeType {
55 Upgrade,
57 SafeDowngrade,
60 UnsafeDowngrade,
63}
64
65impl UpgradeType {
66 #[must_use]
68 pub const fn code(self) -> i8 {
69 match self {
70 Self::Upgrade => 1,
71 Self::SafeDowngrade => 2,
72 Self::UnsafeDowngrade => 3,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct FeatureUpdate {
81 max_version_level: i16,
82 upgrade_type: UpgradeType,
83}
84
85impl FeatureUpdate {
86 pub fn new(max_version_level: i16, upgrade_type: UpgradeType) -> Result<Self, AdminError> {
94 if max_version_level == 0 && upgrade_type == UpgradeType::Upgrade {
95 return Err(AdminError::InvalidArgument(format!(
96 "The upgradeType flag should be set to SAFE_DOWNGRADE or UNSAFE_DOWNGRADE when \
97 the provided maxVersionLevel:{max_version_level} is < 1."
98 )));
99 }
100 if max_version_level < 0 {
101 return Err(AdminError::InvalidArgument(
102 "Cannot specify a negative version level.".into(),
103 ));
104 }
105 Ok(Self {
106 max_version_level,
107 upgrade_type,
108 })
109 }
110
111 #[must_use]
113 pub const fn max_version_level(&self) -> i16 {
114 self.max_version_level
115 }
116
117 #[must_use]
119 pub const fn upgrade_type(&self) -> UpgradeType {
120 self.upgrade_type
121 }
122}
123
124#[derive(Debug, Clone, Copy, Default, PartialEq, bon::Builder)]
127pub struct UpdateFeaturesOptions {
128 #[builder(with = |millis: i64| Time::from_millis(millis))]
132 pub timeout: Option<Time>,
133 #[builder(default)]
136 pub validate_only: bool,
137}
138
139#[derive(Debug, Clone, Copy, Default, PartialEq, bon::Builder)]
142pub struct DescribeFeaturesOptions {
143 #[builder(with = |millis: i64| Time::from_millis(millis))]
146 pub timeout: Option<Time>,
147 pub node_id: Option<i32>,
152}
153
154pub type UpdateFeaturesResults = BTreeMap<String, Result<(), KafkaError>>;
157
158fn call_policy(timeout: Option<Time>, base: RetryPolicy) -> RetryPolicy {
160 timeout.map_or(base, |timeout| RetryPolicy {
161 timeout: Duration::from_millis(u64::try_from(timeout.millis_i64()).unwrap_or(0)),
162 ..base
163 })
164}
165
166fn update_request(
170 updates: &BTreeMap<String, FeatureUpdate>,
171 options: UpdateFeaturesOptions,
172 timeout_ms: i32,
173) -> UpdateFeaturesRequest {
174 UpdateFeaturesRequest {
175 timeout_ms,
176 feature_updates: updates
177 .iter()
178 .map(|(feature, update)| FeatureUpdateKey {
179 feature: feature.clone(),
180 max_version_level: update.max_version_level,
181 upgrade_type: update.upgrade_type.code(),
182 ..Default::default()
183 })
184 .collect(),
185 validate_only: options.validate_only,
186 ..Default::default()
187 }
188}
189
190fn update_min_version(
195 updates: &BTreeMap<String, FeatureUpdate>,
196 options: UpdateFeaturesOptions,
197) -> i16 {
198 let needs_v1 = options.validate_only
199 || updates
200 .values()
201 .any(|update| update.upgrade_type != UpgradeType::Upgrade);
202 if needs_v1 {
203 UPGRADE_TYPE_MIN_VERSION
204 } else {
205 0
206 }
207}
208
209impl AdminClient {
210 pub async fn describe_features(
218 &self,
219 options: DescribeFeaturesOptions,
220 ) -> Result<FeatureMetadata, AdminError> {
221 let request = ApiVersionsRequest {
222 client_software_name: "krabka-client-rs".into(),
223 client_software_version: env!("CARGO_PKG_VERSION").into(),
224 ..Default::default()
225 };
226 let policy = call_policy(options.timeout, self.retry);
227 let response = if let Some(node_id) = options.node_id {
228 call_node(
229 &self.conn,
230 NodeTarget {
231 node_id,
232 supports_controllers: true,
233 min_version: Some(FEATURES_API_VERSIONS_MIN_VERSION),
234 },
235 &self.options,
236 request,
237 CoordinatorRetry::new(policy),
238 )
239 .await
240 .map_err(|error| AdminError::Broker {
241 api: "ApiVersions",
242 code: error.code,
243 name: error.name,
244 message: error.message,
245 })?
246 } else {
247 ControllerRetry::new("ApiVersions", policy)
248 .bounded(
249 self.conn
250 .send_at_least(request, FEATURES_API_VERSIONS_MIN_VERSION),
251 )
252 .await?
253 };
254 parse_feature_metadata(response)
255 }
256
257 pub async fn update_features(
277 &mut self,
278 updates: &BTreeMap<String, FeatureUpdate>,
279 options: UpdateFeaturesOptions,
280 ) -> Result<UpdateFeaturesResults, AdminError> {
281 if updates.is_empty() {
282 return Err(AdminError::InvalidArgument(
283 "Feature updates can not be null or empty.".into(),
284 ));
285 }
286 if updates.keys().any(|feature| feature.trim().is_empty()) {
287 return Err(AdminError::InvalidArgument(
288 "Provided feature can not be empty.".into(),
289 ));
290 }
291 let min_version = update_min_version(updates, options);
292 let mut retry =
293 ControllerRetry::new("UpdateFeatures", call_policy(options.timeout, self.retry));
294 loop {
295 let request = update_request(updates, options, retry.remaining_millis());
296 let response = retry
297 .bounded(self.conn.send_at_least(request, min_version))
298 .await?;
299 if response.error_code != NOT_CONTROLLER {
300 return Ok(update_results(updates, response));
301 }
302 retry.after_not_controller(self).await?;
303 }
304 }
305
306 pub(crate) async fn update_metadata_version_impl(
308 &mut self,
309 level: i16,
310 upgrade_type: UpgradeType,
311 timeout: Time,
312 ) -> Result<MetadataVersionUpdate, AdminError> {
313 let updates = maplit::btreemap! {
314 METADATA_VERSION_FEATURE.to_owned() => FeatureUpdate::new(level, upgrade_type)?,
315 };
316 let results = self
317 .update_features(
318 &updates,
319 UpdateFeaturesOptions::builder()
320 .timeout(timeout.millis_i64())
321 .validate_only(false)
322 .build(),
323 )
324 .await?;
325 if let Some(Err(error)) = results.into_values().next() {
326 return Err(AdminError::Broker {
327 api: "UpdateFeatures",
328 code: error.code,
329 name: error.name,
330 message: error.message,
331 });
332 }
333 Ok(MetadataVersionUpdate { level })
334 }
335}
336
337fn parse_feature_metadata(response: ApiVersionsResponse) -> Result<FeatureMetadata, AdminError> {
338 if response.error_code != 0 {
339 return Err(AdminError::Broker {
340 api: "ApiVersions",
341 code: response.error_code,
342 name: kafka_error_name(response.error_code),
343 message: None,
344 });
345 }
346 Ok(FeatureMetadata {
347 supported: response
348 .supported_features
349 .into_iter()
350 .map(|feature| FeatureRange {
351 name: feature.name,
352 min_version: feature.min_version,
353 max_version: feature.max_version,
354 })
355 .collect(),
356 finalized: response
357 .finalized_features
358 .into_iter()
359 .map(|feature| FeatureRange {
360 name: feature.name,
361 min_version: feature.min_version_level,
362 max_version: feature.max_version_level,
363 })
364 .collect(),
365 finalized_features_epoch: (response.finalized_features_epoch >= 0)
366 .then_some(response.finalized_features_epoch),
367 })
368}
369
370fn update_results(
373 updates: &BTreeMap<String, FeatureUpdate>,
374 response: UpdateFeaturesResponse,
375) -> UpdateFeaturesResults {
376 if let Some(error) = kafka_error_if(response.error_code, response.error_message) {
377 return updates
378 .keys()
379 .map(|feature| (feature.clone(), Err(error.clone())))
380 .collect();
381 }
382 if response.results.is_empty() {
383 return updates
384 .keys()
385 .map(|feature| (feature.clone(), Ok(())))
386 .collect();
387 }
388 let mut results = BTreeMap::new();
389 for result in response.results {
390 if !updates.contains_key(&result.feature) {
391 tracing::warn!(
392 feature = %result.feature,
393 "server response mentioned unknown feature"
394 );
395 continue;
396 }
397 let outcome = kafka_error_if(result.error_code, result.error_message).map_or(Ok(()), Err);
398 results.insert(result.feature, outcome);
399 }
400 for feature in updates.keys() {
401 results.entry(feature.clone()).or_insert_with(|| {
402 Err(KafkaError {
403 code: UNKNOWN_SERVER_ERROR,
404 name: kafka_error_name(UNKNOWN_SERVER_ERROR),
405 message: Some(format!(
406 "The controller response did not contain a result for feature {feature}"
407 )),
408 })
409 });
410 }
411 results
412}
413
414#[cfg(test)]
415mod tests {
416 use std::{
417 net::SocketAddr,
418 sync::{Arc, Mutex},
419 };
420
421 use krabka_client_core::{ClientError, MockBroker, MockReply};
422 use krabka_protocol::owned::{
423 api_versions_request,
424 api_versions_response::{ApiVersion, FinalizedFeatureKey, SupportedFeatureKey},
425 metadata_request,
426 metadata_response::{MetadataResponse, MetadataResponseBroker},
427 update_features_request,
428 update_features_response::UpdatableFeatureResult,
429 };
430
431 use super::*;
432 use crate::{
433 AdminClientLike as _,
434 partition_leaders::test_support::{
435 ApiVersionRange, api_versions, decode_request, encode_response, fast_admin,
436 },
437 retry::REQUEST_TIMED_OUT,
438 };
439
440 fn update(level: i16, upgrade_type: UpgradeType) -> FeatureUpdate {
441 FeatureUpdate::new(level, upgrade_type).expect("valid update")
442 }
443
444 fn key(feature: &str, max_version_level: i16, upgrade_type: i8) -> FeatureUpdateKey {
445 FeatureUpdateKey {
446 feature: feature.into(),
447 max_version_level,
448 upgrade_type,
449 ..Default::default()
450 }
451 }
452
453 fn error(code: i16, message: Option<&str>) -> KafkaError {
454 KafkaError {
455 code,
456 name: kafka_error_name(code),
457 message: message.map(Into::into),
458 }
459 }
460
461 #[test]
462 fn feature_update_refuses_what_kafka_refuses() {
463 for (name, level, upgrade_type, ok) in [
464 ("upgrade to 1", 1, UpgradeType::Upgrade, true),
465 ("safe delete", 0, UpgradeType::SafeDowngrade, true),
466 ("unsafe delete", 0, UpgradeType::UnsafeDowngrade, true),
467 ("upgrade to 0", 0, UpgradeType::Upgrade, false),
468 ("negative level", -1, UpgradeType::SafeDowngrade, false),
469 ] {
470 let result = FeatureUpdate::new(level, upgrade_type);
471 match (ok, result) {
472 (true, Ok(update)) => assert2::assert!(
473 (update.max_version_level(), update.upgrade_type()) == (level, upgrade_type),
474 "case {name}"
475 ),
476 (false, Err(AdminError::InvalidArgument(_))) => {}
477 (_, other) => panic!("case {name}: {other:?}"),
478 }
479 }
480 }
481
482 #[test]
483 fn upgrade_type_codes_match_kafka() {
484 assert2::assert!(
485 [
486 UpgradeType::Upgrade.code(),
487 UpgradeType::SafeDowngrade.code(),
488 UpgradeType::UnsafeDowngrade.code(),
489 ] == [1, 2, 3]
490 );
491 }
492
493 #[test]
494 fn describe_features_preserves_supported_finalized_and_epoch() {
495 for (name, epoch, expected_epoch) in
496 [("finalized", 7, Some(7)), ("no finalized epoch", -1, None)]
497 {
498 let metadata = parse_feature_metadata(ApiVersionsResponse {
499 supported_features: vec![SupportedFeatureKey {
500 name: "kraft.version".into(),
501 min_version: 0,
502 max_version: 1,
503 ..Default::default()
504 }],
505 finalized_features: vec![FinalizedFeatureKey {
506 name: "kraft.version".into(),
507 min_version_level: 1,
508 max_version_level: 1,
509 ..Default::default()
510 }],
511 finalized_features_epoch: epoch,
512 ..Default::default()
513 })
514 .unwrap();
515 assert2::assert!(
516 metadata
517 == FeatureMetadata {
518 supported: vec![FeatureRange {
519 name: "kraft.version".into(),
520 min_version: 0,
521 max_version: 1,
522 }],
523 finalized: vec![FeatureRange {
524 name: "kraft.version".into(),
525 min_version: 1,
526 max_version: 1,
527 }],
528 finalized_features_epoch: expected_epoch,
529 },
530 "case {name}"
531 );
532 }
533 }
534
535 #[test]
536 fn update_results_complete_every_feature_as_kafka_does() {
537 let updates = maplit::btreemap! {
538 "group.version".to_owned() => update(1, UpgradeType::Upgrade),
539 "metadata.version".to_owned() => update(20, UpgradeType::Upgrade),
540 };
541 let row = |feature: &str, error_code, message: Option<&str>| UpdatableFeatureResult {
542 feature: feature.into(),
543 error_code,
544 error_message: message.map(Into::into),
545 ..Default::default()
546 };
547 let missing = "The controller response did not contain a result for feature \
548 metadata.version";
549 for (name, response, expected) in [
550 (
551 "v2 success has no rows",
552 UpdateFeaturesResponse::default(),
553 [Ok(()), Ok(())],
554 ),
555 (
556 "top-level error fails every feature",
557 UpdateFeaturesResponse {
558 error_code: 95,
559 error_message: Some("bad".into()),
560 ..Default::default()
561 },
562 [Err(error(95, Some("bad"))), Err(error(95, Some("bad")))],
563 ),
564 (
565 "per-feature rows, one missing, one unknown",
566 UpdateFeaturesResponse {
567 results: vec![
568 row("group.version", 95, Some("no")),
569 row("other.version", 0, None),
570 ],
571 ..Default::default()
572 },
573 [
574 Err(error(95, Some("no"))),
575 Err(error(UNKNOWN_SERVER_ERROR, Some(missing))),
576 ],
577 ),
578 ] {
579 let [group, metadata] = expected;
580 assert2::assert!(
581 update_results(&updates, response)
582 == maplit::btreemap! {
583 "group.version".to_owned() => group,
584 "metadata.version".to_owned() => metadata,
585 },
586 "case {name}"
587 );
588 }
589 }
590
591 async fn update_features_broker(
595 max_version: i16,
596 seen: Arc<Mutex<Vec<(i16, UpdateFeaturesRequest)>>>,
597 ) -> MockBroker {
598 MockBroker::start(move |api_key, version, _, body| match api_key {
599 api_versions_request::API_KEY => Some(
600 api_versions()
601 .apis(vec![
602 ApiVersionRange::builder()
603 .api_key(update_features_request::API_KEY)
604 .max_version(max_version)
605 .build(),
606 ])
607 .call(),
608 ),
609 update_features_request::API_KEY => {
610 let mut request: UpdateFeaturesRequest = decode_request(body, version, true);
611 request.timeout_ms = 0;
613 seen.lock().expect("seen lock").push((version, request));
614 Some(encode_response(
615 &UpdateFeaturesResponse::default(),
616 version,
617 true,
618 ))
619 }
620 _ => None,
621 })
622 .await
623 .unwrap()
624 }
625
626 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
634 async fn update_features_sends_kafkas_request_per_version() {
635 let upgrade_types = [
636 UpgradeType::Upgrade,
637 UpgradeType::SafeDowngrade,
638 UpgradeType::UnsafeDowngrade,
639 ];
640 for max_version in 0..=2 {
641 for upgrade_type in upgrade_types {
642 for validate_only in [false, true] {
643 let name = format!("v{max_version} {upgrade_type:?} validate={validate_only}");
644 let seen = Arc::new(Mutex::new(Vec::new()));
645 let broker = update_features_broker(max_version, Arc::clone(&seen)).await;
646 let mut admin = AdminClient::connect(&[broker.addr.to_string()])
647 .await
648 .expect("admin connects");
649 let level = if upgrade_type == UpgradeType::Upgrade {
650 7
651 } else {
652 0
653 };
654 let updates = maplit::btreemap! {
655 "group.version".to_owned() => update(1, upgrade_type),
656 "share.version".to_owned() => update(level, upgrade_type),
657 };
658
659 let result = admin
660 .update_features(
661 &updates,
662 UpdateFeaturesOptions::builder()
663 .validate_only(validate_only)
664 .build(),
665 )
666 .await;
667 broker.stop();
668
669 let sendable = max_version >= 1
670 || (upgrade_type == UpgradeType::Upgrade && !validate_only);
671 if !sendable {
672 assert2::assert!(
673 matches!(
674 result,
675 Err(AdminError::Transport(ClientError::IncompatibleVersion {
676 api_key: update_features_request::API_KEY,
677 client_min: 1,
678 ..
679 }))
680 ),
681 "case {name}: {result:?}"
682 );
683 assert2::assert!(seen.lock().unwrap().is_empty(), "case {name}");
684 continue;
685 }
686 let code = upgrade_type.code();
687 let (wire_code, wire_validate) = if max_version == 0 {
689 (1, false)
690 } else {
691 (code, validate_only)
692 };
693 assert2::assert!(
694 result.expect("update succeeds")
695 == maplit::btreemap! {
696 "group.version".to_owned() => Ok(()),
697 "share.version".to_owned() => Ok(()),
698 },
699 "case {name}"
700 );
701 assert2::assert!(
702 *seen.lock().unwrap()
703 == vec![(
704 max_version,
705 UpdateFeaturesRequest {
706 timeout_ms: 0,
707 feature_updates: vec![
708 key("group.version", 1, wire_code),
709 key("share.version", level, wire_code),
710 ],
711 validate_only: wire_validate,
712 ..Default::default()
713 }
714 )],
715 "case {name}"
716 );
717 }
718 }
719 }
720 }
721
722 #[tokio::test]
723 async fn update_features_refuses_empty_or_blank_features_before_sending() {
724 let broker = MockBroker::start(|api_key, _, _, _| {
725 (api_key == api_versions_request::API_KEY).then(|| {
726 api_versions()
727 .apis(vec![
728 ApiVersionRange::builder()
729 .api_key(update_features_request::API_KEY)
730 .max_version(2)
731 .build(),
732 ])
733 .call()
734 })
735 })
736 .await
737 .unwrap();
738 let mut admin = AdminClient::connect(&[broker.addr.to_string()])
739 .await
740 .expect("admin connects");
741 for (name, updates) in [
742 ("empty", BTreeMap::new()),
743 (
744 "blank",
745 maplit::btreemap! {
746 " ".to_owned() => update(1, UpgradeType::Upgrade),
747 },
748 ),
749 ] {
750 let result = admin
751 .update_features(&updates, UpdateFeaturesOptions::builder().build())
752 .await;
753 assert2::assert!(
754 matches!(result, Err(AdminError::InvalidArgument(_))),
755 "case {name}"
756 );
757 }
758 broker.stop();
759 }
760
761 async fn feature_node(
764 supported: &'static str,
765 nodes: Arc<Mutex<Vec<(i32, SocketAddr)>>>,
766 ) -> MockBroker {
767 MockBroker::start_with_replies(move |api_key, version, _, _| match api_key {
768 api_versions_request::API_KEY => MockReply::Respond(encode_response(
769 &ApiVersionsResponse {
770 api_keys: vec![
771 ApiVersion {
772 api_key: api_versions_request::API_KEY,
773 min_version: 0,
774 max_version: 4,
775 ..Default::default()
776 },
777 ApiVersion {
778 api_key: metadata_request::API_KEY,
779 min_version: 12,
780 max_version: 12,
781 ..Default::default()
782 },
783 ],
784 supported_features: vec![SupportedFeatureKey {
785 name: supported.into(),
786 min_version: 0,
787 max_version: 1,
788 ..Default::default()
789 }],
790 finalized_features_epoch: 3,
791 ..Default::default()
792 },
793 version,
794 false,
795 )),
796 metadata_request::API_KEY => MockReply::Respond(encode_response(
797 &MetadataResponse {
798 controller_id: 1,
799 brokers: nodes
800 .lock()
801 .expect("nodes lock")
802 .iter()
803 .map(|(node_id, addr)| MetadataResponseBroker {
804 node_id: *node_id,
805 host: addr.ip().to_string(),
806 port: i32::from(addr.port()),
807 ..Default::default()
808 })
809 .collect(),
810 ..Default::default()
811 },
812 version,
813 true,
814 )),
815 _ => MockReply::Silent,
816 })
817 .await
818 .unwrap()
819 }
820
821 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
826 async fn describe_features_asks_the_named_node() {
827 let nodes = Arc::new(Mutex::new(Vec::new()));
828 let first = feature_node("on.node.1", Arc::clone(&nodes)).await;
829 let second = feature_node("on.node.2", Arc::clone(&nodes)).await;
830 *nodes.lock().unwrap() = vec![(1, first.addr), (2, second.addr)];
831 let admin = fast_admin(first.addr, krabka_units::secs(5)).await;
832
833 for (name, node_id, feature) in [
834 ("any node", None, "on.node.1"),
835 ("node 1", Some(1), "on.node.1"),
836 ("node 2", Some(2), "on.node.2"),
837 ] {
838 let metadata = admin
839 .describe_features(
840 DescribeFeaturesOptions::builder()
841 .maybe_node_id(node_id)
842 .build(),
843 )
844 .await
845 .expect("describe succeeds");
846 assert2::assert!(
847 metadata
848 == FeatureMetadata {
849 supported: vec![FeatureRange {
850 name: feature.into(),
851 min_version: 0,
852 max_version: 1,
853 }],
854 finalized: Vec::new(),
855 finalized_features_epoch: Some(3),
856 },
857 "case {name}"
858 );
859 }
860
861 let unknown = admin
862 .describe_features(
863 DescribeFeaturesOptions::builder()
864 .timeout(50)
865 .node_id(9)
866 .build(),
867 )
868 .await;
869 assert2::assert!(
870 matches!(
871 unknown,
872 Err(AdminError::Broker {
873 code: REQUEST_TIMED_OUT,
874 ..
875 })
876 ),
877 "{unknown:?}"
878 );
879 first.stop();
880 second.stop();
881 }
882
883 #[tokio::test]
884 async fn update_metadata_version_surfaces_the_feature_error() {
885 let broker = MockBroker::start(|api_key, version, _, _| match api_key {
886 api_versions_request::API_KEY => Some(
887 api_versions()
888 .apis(vec![
889 ApiVersionRange::builder()
890 .api_key(update_features_request::API_KEY)
891 .max_version(1)
892 .build(),
893 ])
894 .call(),
895 ),
896 update_features_request::API_KEY => Some(encode_response(
897 &UpdateFeaturesResponse {
898 results: vec![UpdatableFeatureResult {
899 feature: METADATA_VERSION_FEATURE.into(),
900 error_code: 95,
901 error_message: Some("not supported by every broker".into()),
902 ..Default::default()
903 }],
904 ..Default::default()
905 },
906 version,
907 true,
908 )),
909 _ => None,
910 })
911 .await
912 .unwrap();
913 let mut admin = AdminClient::connect(&[broker.addr.to_string()])
914 .await
915 .expect("admin connects");
916 let result = admin
917 .update_metadata_version(15, UpgradeType::SafeDowngrade, krabka_units::secs(5))
918 .await;
919 broker.stop();
920 assert2::assert!(
921 matches!(
922 result,
923 Err(AdminError::Broker {
924 api: "UpdateFeatures",
925 code: 95,
926 ..
927 })
928 ),
929 "{result:?}"
930 );
931 }
932}