1use std::collections::{BTreeMap, HashMap, HashSet};
12
13use krabka_protocol::{
14 owned::{
15 consumer_group_heartbeat_request,
16 consumer_group_heartbeat_request::{ConsumerGroupHeartbeatRequest, TopicPartitions},
17 consumer_group_heartbeat_response::ConsumerGroupHeartbeatResponse,
18 metadata_request::{MetadataRequest, MetadataRequestTopic},
19 },
20 primitives::uuid::Uuid as WireUuid,
21};
22use krabka_units::convert::TimeExt as _;
23
24use crate::{consumer, coordinator, error, offset_wire, rebalance_listener};
25
26#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
28pub enum GroupProtocol {
29 #[default]
31 Classic,
32 Consumer,
34}
35
36pub(crate) const JOIN_GROUP_MEMBER_EPOCH: i32 = 0;
38pub(crate) const LEAVE_GROUP_MEMBER_EPOCH: i32 = -1;
40pub(crate) const LEAVE_GROUP_STATIC_MEMBER_EPOCH: i32 = -2;
42
43pub(crate) const FENCED_MEMBER_EPOCH: i16 = 110;
45const UNKNOWN_MEMBER_ID: i16 = 25;
47#[cfg(test)]
49const UNSUPPORTED_ASSIGNOR: i16 = 112;
50
51pub(crate) const RE2J_NEEDS_CONSUMER_PROTOCOL: &str = "Subscribe to RE2/J pattern is not supported when using the CLASSIC protocol defined in config group.protocol";
54
55pub(crate) type ResolvedAssignment = (Vec<(String, i32)>, HashSet<WireUuid>);
58
59pub(crate) type OwnedByTopicId = BTreeMap<[u8; 16], Vec<i32>>;
62
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
67pub(crate) struct SentFields {
68 rebalance_timeout_ms: Option<i32>,
69 subscribed_topic_names: Option<Vec<String>>,
70 regex: Option<String>,
71 server_assignor: Option<String>,
72 topic_partitions: Option<OwnedByTopicId>,
73}
74
75#[derive(Clone, Debug)]
77pub(crate) struct HeartbeatFields<'a> {
78 pub group_id: &'a str,
79 pub member_id: &'a str,
80 pub member_epoch: i32,
81 pub instance_id: Option<&'a str>,
82 pub rack_id: Option<&'a str>,
83 pub rebalance_timeout_ms: i32,
84 pub subscribed_topic_names: Vec<String>,
87 pub regex: Option<String>,
89 pub server_assignor: Option<&'a str>,
90 pub owned: OwnedByTopicId,
92 pub joining: bool,
94}
95
96pub(crate) fn build_heartbeat(
99 fields: &HeartbeatFields<'_>,
100 sent: &mut SentFields,
101) -> ConsumerGroupHeartbeatRequest {
102 let mut request = ConsumerGroupHeartbeatRequest {
103 group_id: fields.group_id.to_owned(),
104 member_id: fields.member_id.to_owned(),
105 member_epoch: fields.member_epoch,
106 instance_id: fields.instance_id.map(str::to_owned),
107 ..Default::default()
108 };
109 let all = fields.joining;
110 if all {
111 request.rack_id = fields.rack_id.map(str::to_owned);
113 *sent = SentFields::default();
114 }
115 if all || sent.rebalance_timeout_ms != Some(fields.rebalance_timeout_ms) {
116 request.rebalance_timeout_ms = fields.rebalance_timeout_ms;
117 sent.rebalance_timeout_ms = Some(fields.rebalance_timeout_ms);
118 } else {
119 request.rebalance_timeout_ms = -1;
120 }
121 if all || sent.subscribed_topic_names.as_ref() != Some(&fields.subscribed_topic_names) {
122 request.subscribed_topic_names = Some(fields.subscribed_topic_names.clone());
123 sent.subscribed_topic_names = Some(fields.subscribed_topic_names.clone());
124 }
125 let pattern_changed = sent.regex != fields.regex;
129 if (all && fields.regex.is_some()) || pattern_changed {
130 request.subscribed_topic_regex = Some(fields.regex.clone().unwrap_or_default());
131 sent.regex.clone_from(&fields.regex);
132 }
133 if let Some(assignor) = fields.server_assignor
134 && (all || sent.server_assignor.as_deref() != Some(assignor))
135 {
136 request.server_assignor = Some(assignor.to_owned());
137 sent.server_assignor = Some(assignor.to_owned());
138 }
139 if all || sent.topic_partitions.as_ref() != Some(&fields.owned) {
140 request.topic_partitions = Some(
141 fields
142 .owned
143 .iter()
144 .map(|(topic_id, partitions)| TopicPartitions {
145 topic_id: WireUuid(*topic_id),
146 partitions: partitions.clone(),
147 ..Default::default()
148 })
149 .collect(),
150 );
151 sent.topic_partitions = Some(fields.owned.clone());
152 }
153 request
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
159pub(crate) enum HeartbeatAction {
160 Ok,
162 Retry,
164 FindCoordinator,
166 Fenced,
169 Fatal,
171}
172
173pub(crate) fn heartbeat_action(error_code: i16) -> HeartbeatAction {
184 match error_code {
185 0 => HeartbeatAction::Ok,
186 coordinator::COORDINATOR_NOT_AVAILABLE | coordinator::NOT_COORDINATOR => {
187 HeartbeatAction::FindCoordinator
188 }
189 coordinator::COORDINATOR_LOAD_IN_PROGRESS => HeartbeatAction::Retry,
190 FENCED_MEMBER_EPOCH | UNKNOWN_MEMBER_ID => HeartbeatAction::Fenced,
191 _ => HeartbeatAction::Fatal,
196 }
197}
198
199pub(crate) fn leave_group_epoch(
203 group_instance_id: Option<&str>,
204 operation: consumer::GroupMembershipOperation,
205) -> i32 {
206 if operation == consumer::GroupMembershipOperation::LeaveGroup {
207 return LEAVE_GROUP_MEMBER_EPOCH;
208 }
209 if group_instance_id.is_some() {
210 LEAVE_GROUP_STATIC_MEMBER_EPOCH
211 } else {
212 LEAVE_GROUP_MEMBER_EPOCH
213 }
214}
215
216pub(crate) fn should_send_leave_heartbeat(
221 member_id: &str,
222 group_instance_id: Option<&str>,
223 operation: consumer::GroupMembershipOperation,
224) -> bool {
225 if member_id.is_empty() {
226 return false;
227 }
228 !(group_instance_id.is_none() && operation == consumer::GroupMembershipOperation::RemainInGroup)
229}
230
231pub(crate) fn assignment_partitions(
237 response: &ConsumerGroupHeartbeatResponse,
238 names: &HashMap<WireUuid, String>,
239) -> Option<ResolvedAssignment> {
240 let assignment = response.assignment.as_ref()?;
241 let mut partitions = Vec::new();
242 let mut unresolved = HashSet::new();
243 for topic in &assignment.topic_partitions {
244 match names.get(&topic.topic_id) {
245 Some(name) => partitions.extend(
246 topic
247 .partitions
248 .iter()
249 .map(|partition| (name.clone(), *partition)),
250 ),
251 None => {
252 unresolved.insert(topic.topic_id);
253 }
254 }
255 }
256 partitions.sort();
257 Some((partitions, unresolved))
258}
259
260pub(crate) fn owned_by_topic_id(
262 owned: &[(String, i32)],
263 topic_ids: &HashMap<String, WireUuid>,
264) -> OwnedByTopicId {
265 let mut by_id: OwnedByTopicId = BTreeMap::new();
266 for (topic, partition) in owned {
267 if let Some(topic_id) = topic_ids.get(topic) {
268 by_id.entry(topic_id.0).or_default().push(*partition);
269 }
270 }
271 for partitions in by_id.values_mut() {
272 partitions.sort_unstable();
273 }
274 by_id
275}
276
277pub(crate) async fn run(
285 mut state: coordinator::CoordinatorState,
286 shutdown: tokio_util::sync::CancellationToken,
287) {
288 if let Err(error) = state.client.refresh_metadata().await {
289 tracing::warn!(%error, "coordinator client metadata refresh failed at startup");
290 }
291 state.poll_timer = coordinator::PollTimer::new(state.max_poll_interval);
294 let mut interval = state.heartbeat_interval.to_std();
295 let mut send_now = true;
296 let mut polls_open = true;
297 let mut left_on_poll_timeout = false;
300 let mut unsubscribed = false;
303 let mut fatal = false;
306 let mut last_pattern_check: Option<tokio::time::Instant> = None;
307 loop {
308 if !send_now {
309 let event = tokio::select! {
310 biased;
311 () = shutdown.cancelled() => break,
312 () = tokio::time::sleep(interval), if !left_on_poll_timeout && !unsubscribed => {
313 TaskEvent::Heartbeat
314 }
315 changed = state.polls.changed(), if polls_open => {
316 if changed.is_err() {
317 polls_open = false;
318 continue;
319 }
320 TaskEvent::Poll
321 }
322 changed = state.subscription_changes.changed() => {
323 if changed.is_err() {
324 break;
325 }
326 TaskEvent::Subscription
327 }
328 () = tokio::time::sleep_until(state.poll_timer.deadline()),
329 if !left_on_poll_timeout && !unsubscribed =>
330 {
331 TaskEvent::PollTimeout
332 }
333 };
334 match event {
335 TaskEvent::Poll => {
336 state.poll_timer.reset();
337 if !left_on_poll_timeout {
338 continue;
339 }
340 left_on_poll_timeout = false;
344 state.member_epoch = JOIN_GROUP_MEMBER_EPOCH;
345 state.sent_heartbeat_fields = SentFields::default();
346 }
347 TaskEvent::PollTimeout => {
348 leave_after_poll_timeout(&mut state).await;
349 left_on_poll_timeout = true;
350 continue;
351 }
352 TaskEvent::Subscription => {
353 let subscription = state.subscription.borrow().clone();
354 if subscription.unsubscribes != state.seen_unsubscribes {
355 state.seen_unsubscribes = subscription.unsubscribes;
356 leave_after_unsubscribe(&mut state).await;
357 unsubscribed = true;
358 continue;
359 }
360 if subscription.is_none() {
361 continue;
362 }
363 if unsubscribed {
364 unsubscribed = false;
366 state.member_epoch = JOIN_GROUP_MEMBER_EPOCH;
367 state.sent_heartbeat_fields = SentFields::default();
368 }
369 if !subscription.topics.is_empty() {
373 state.client.metadata_topics().set(subscription.topics);
374 }
375 }
376 TaskEvent::Heartbeat => {}
377 }
378 }
379 if state.subscription.borrow().pattern.is_some()
383 && last_pattern_check.is_none_or(|last| {
384 coordinator::subscription_metadata_refresh_due(
385 last,
386 state.subscription_metadata_refresh_interval,
387 )
388 })
389 {
390 last_pattern_check = Some(tokio::time::Instant::now());
391 coordinator::refresh_pattern_topics(&mut state).await;
392 }
393 send_now = false;
394 let outcome = tokio::select! {
395 biased;
396 () = shutdown.cancelled() => break,
397 outcome = heartbeat_once(&mut state) => outcome,
398 };
399 match outcome {
400 HeartbeatOutcome::Interval(next) => interval = next,
401 HeartbeatOutcome::Reconciled(next) => {
402 interval = next;
403 send_now = true;
406 }
407 HeartbeatOutcome::Backoff => {
408 tokio::select! {
409 () = shutdown.cancelled() => break,
410 () = tokio::time::sleep(state.retry_policy.initial_backoff) => {}
411 }
412 send_now = true;
413 }
414 HeartbeatOutcome::Stop => {
415 fatal = true;
416 break;
417 }
418 }
419 }
420 let close = *state.close_operation.borrow();
421 if !left_on_poll_timeout
426 && !unsubscribed
427 && !fatal
428 && should_send_leave_heartbeat(
429 &state.member_id,
430 state.group_instance_id.as_deref(),
431 close.operation,
432 )
433 {
434 let epoch = leave_group_epoch(state.group_instance_id.as_deref(), close.operation);
435 state.member_epoch = epoch;
436 let leave_timeout = state.leave_group_timeout.to_std();
437 let leave = send_heartbeat(&mut state, HeartbeatKind::Leave).await;
438 let _ = tokio::time::timeout(leave_timeout, leave.send()).await;
439 }
440}
441
442#[derive(Clone, Copy, Debug, Eq, PartialEq)]
444enum TaskEvent {
445 Heartbeat,
447 Poll,
449 Subscription,
451 PollTimeout,
453}
454
455async fn leave_after_poll_timeout(state: &mut coordinator::CoordinatorState) {
462 coordinator::warn_poll_timeout(state);
463 set_leave_epoch(state);
464 let leave_timeout = state.leave_group_timeout.to_std();
465 let leave = send_heartbeat(state, HeartbeatKind::Leave).await;
466 let _ = tokio::time::timeout(leave_timeout, leave.send()).await;
467 coordinator::release_partitions_after_poll_timeout(state).await;
468 if let Err(error) = coordinator::call_lost_listener(state).await {
469 tracing::warn!(%error, group = %state.group_id, "the lost callback failed");
470 }
471 state.member_epoch = JOIN_GROUP_MEMBER_EPOCH;
472 state.sent_heartbeat_fields = SentFields::default();
473}
474
475async fn leave_after_unsubscribe(state: &mut coordinator::CoordinatorState) {
480 set_leave_epoch(state);
481 let leave_timeout = state.leave_group_timeout.to_std();
482 let leave = send_heartbeat(state, HeartbeatKind::Leave).await;
483 let _ = tokio::time::timeout(leave_timeout, leave.send()).await;
484 coordinator::forget_member_keeping_id(state).await;
485 state.rebalance_pending.send_replace(false);
486 state.member_epoch = JOIN_GROUP_MEMBER_EPOCH;
487 state.sent_heartbeat_fields = SentFields::default();
488}
489
490fn set_leave_epoch(state: &mut coordinator::CoordinatorState) {
491 state.member_epoch = leave_group_epoch(
492 state.group_instance_id.as_deref(),
493 consumer::GroupMembershipOperation::Default,
494 );
495}
496
497pub(crate) async fn heartbeat_during_callback(
505 state: &coordinator::CoordinatorState,
506 done: &tokio_util::sync::CancellationToken,
507) {
508 let mut ticker = coordinator::heartbeat_ticker(state.heartbeat_interval);
509 loop {
510 tokio::select! {
511 biased;
512 () = done.cancelled() => return,
513 _ = ticker.tick() => {}
514 }
515 let mut sent = state.sent_heartbeat_fields.clone();
516 let fields = heartbeat_fields(state, HeartbeatKind::Interval).await;
517 let request = build_heartbeat(&fields, &mut sent);
518 let broker = state.client.broker(
519 state
520 .coordinator_id
521 .load(std::sync::atomic::Ordering::Relaxed),
522 );
523 let call = HeartbeatCall {
524 broker,
525 request,
526 needs_regex: state.subscription.borrow().regex.is_some(),
527 };
528 let result = tokio::select! {
529 biased;
530 () = done.cancelled() => return,
531 result = call.send() => result,
532 };
533 if let Err(error) = result {
534 tracing::warn!(%error, group = %state.group_id, "the heartbeat during a rebalance callback failed");
535 }
536 }
537}
538
539#[derive(Clone, Copy, Debug, Eq, PartialEq)]
541enum HeartbeatOutcome {
542 Interval(std::time::Duration),
544 Reconciled(std::time::Duration),
547 Backoff,
549 Stop,
551}
552
553async fn heartbeat_once(state: &mut coordinator::CoordinatorState) -> HeartbeatOutcome {
555 let kind = if state.member_epoch == JOIN_GROUP_MEMBER_EPOCH {
556 HeartbeatKind::Join
557 } else {
558 HeartbeatKind::Interval
559 };
560 let response = match send_heartbeat(state, kind).await.send().await {
561 Ok(response) => response,
562 Err(error) => {
563 tracing::warn!(%error, group = %state.group_id, "consumer group heartbeat failed");
564 state.sent_heartbeat_fields = SentFields::default();
568 if !coordinator::is_retriable_transport_error(&error) {
569 tracing::error!(
573 %error,
574 group = %state.group_id,
575 "the consumer group heartbeat failed for good; the member stops"
576 );
577 coordinator::report_fatal_error(
578 &state.poll_error,
579 error::ConsumerError::Client(error),
580 );
581 return HeartbeatOutcome::Stop;
582 }
583 state.client.evict_broker(
584 state
585 .coordinator_id
586 .load(std::sync::atomic::Ordering::Relaxed),
587 );
588 return HeartbeatOutcome::Backoff;
589 }
590 };
591 let interval = heartbeat_interval(response.heartbeat_interval_ms, state.heartbeat_interval);
592 if response.error_code != 0 {
593 state.sent_heartbeat_fields = SentFields::default();
596 }
597 match heartbeat_action(response.error_code) {
598 HeartbeatAction::Ok => {}
599 HeartbeatAction::Retry => return HeartbeatOutcome::Backoff,
600 HeartbeatAction::FindCoordinator => {
601 coordinator::find_coordinator_again(state).await;
602 return HeartbeatOutcome::Backoff;
603 }
604 HeartbeatAction::Fenced => {
605 tracing::warn!(
606 group = %state.group_id,
607 error_code = response.error_code,
608 "the coordinator fenced the member; giving up the partitions and joining again"
609 );
610 state.member_epoch = JOIN_GROUP_MEMBER_EPOCH;
611 coordinator::forget_member_keeping_id(state).await;
612 if let Err(error) = coordinator::call_lost_listener(state).await {
615 tracing::warn!(%error, group = %state.group_id, "the lost callback failed");
616 }
617 return HeartbeatOutcome::Backoff;
618 }
619 HeartbeatAction::Fatal => {
620 tracing::error!(
621 group = %state.group_id,
622 error_code = response.error_code,
623 error = ?response.error_message,
624 "the consumer group heartbeat failed with a fatal error"
625 );
626 coordinator::report_fatal_error(
627 &state.poll_error,
628 error::ConsumerError::Server(response.error_code),
629 );
630 return HeartbeatOutcome::Stop;
631 }
632 }
633 if let Some(member_id) = response.member_id.clone().filter(|id| !id.is_empty()) {
634 state.member_id = member_id;
635 }
636 state.member_epoch = response.member_epoch;
637 coordinator::publish_member_epoch(state).await;
638 let names = offset_wire::id_to_name(&state.topic_ids.lock().await.clone());
639 let Some((mut target, mut unresolved)) = assignment_partitions(&response, &names) else {
640 state.rebalance_pending.send_replace(false);
641 return HeartbeatOutcome::Interval(interval);
642 };
643 if !unresolved.is_empty() {
644 refresh_topic_ids(state, &unresolved).await;
649 let names = offset_wire::id_to_name(&state.topic_ids.lock().await.clone());
650 if let Some(resolved) = assignment_partitions(&response, &names) {
651 (target, unresolved) = resolved;
652 }
653 }
654 if !unresolved.is_empty() {
655 tracing::info!(
656 group = %state.group_id,
657 topics = unresolved.len(),
658 "the assignment names topic ids that the metadata does not name yet"
659 );
660 }
661 let topics: Vec<String> = target.iter().map(|(topic, _)| topic.clone()).collect();
665 state
666 .subscription
667 .send_if_modified(|subscription| subscription.store_assigned_topics(&topics));
668 match reconcile(state, &target).await {
669 Ok(true) => HeartbeatOutcome::Reconciled(interval),
673 Ok(false) => HeartbeatOutcome::Interval(interval),
674 Err(error) => {
675 tracing::warn!(%error, group = %state.group_id, "the reconciliation of the assignment failed");
676 HeartbeatOutcome::Backoff
677 }
678 }
679}
680
681#[derive(Clone, Copy, Debug, Eq, PartialEq)]
688enum HeartbeatKind {
689 Join,
691 Interval,
693 Leave,
695}
696
697async fn send_heartbeat(
704 state: &mut coordinator::CoordinatorState,
705 kind: HeartbeatKind,
706) -> HeartbeatCall<'_> {
707 let mut sent = state.sent_heartbeat_fields.clone();
708 let needs_regex = state.subscription.borrow().regex.is_some();
709 let request = build_heartbeat(&heartbeat_fields(state, kind).await, &mut sent);
710 state.sent_heartbeat_fields = sent;
711 let broker = state.client.broker(
712 state
713 .coordinator_id
714 .load(std::sync::atomic::Ordering::Relaxed),
715 );
716 HeartbeatCall {
717 broker,
718 request,
719 needs_regex,
720 }
721}
722
723async fn heartbeat_fields(
725 state: &coordinator::CoordinatorState,
726 kind: HeartbeatKind,
727) -> HeartbeatFields<'_> {
728 let topic_ids = state.topic_ids.lock().await.clone();
729 let owned = if kind == HeartbeatKind::Leave {
730 OwnedByTopicId::new()
731 } else {
732 owned_by_topic_id(&state.assigned.lock().await, &topic_ids)
733 };
734 let (subscribed_topic_names, regex) = {
735 let subscription = state.subscription.borrow();
736 let topics = if subscription.regex.is_some() {
740 Vec::new()
741 } else {
742 subscription.topics.clone()
743 };
744 (topics, subscription.regex.clone())
745 };
746 HeartbeatFields {
747 group_id: &state.group_id,
748 member_id: &state.member_id,
749 member_epoch: state.member_epoch,
750 instance_id: state.group_instance_id.as_deref(),
751 rack_id: state.client_rack.as_deref(),
752 rebalance_timeout_ms: consumer::protocol_millis_i32(state.max_poll_interval),
753 subscribed_topic_names,
754 regex,
755 server_assignor: state.server_assignor.as_deref(),
756 owned,
757 joining: kind == HeartbeatKind::Join,
758 }
759}
760
761struct RegexHeartbeat(ConsumerGroupHeartbeatRequest);
765
766impl krabka_protocol::Encode for RegexHeartbeat {
767 fn encode<B: bytes::BufMut>(
768 &self,
769 buf: &mut B,
770 version: i16,
771 ) -> Result<(), krabka_protocol::ProtocolError> {
772 self.0.encode(buf, version)
773 }
774
775 fn encoded_len(&self, version: i16) -> usize {
776 self.0.encoded_len(version)
777 }
778}
779
780impl krabka_protocol::ProtocolRequest for RegexHeartbeat {
781 const API_KEY: i16 = consumer_group_heartbeat_request::API_KEY;
782 const MIN_VERSION: i16 = 1;
783 const MAX_VERSION: i16 = consumer_group_heartbeat_request::MAX_VERSION;
784 const LATEST_STABLE_VERSION: i16 = consumer_group_heartbeat_request::LATEST_STABLE_VERSION;
785 const FLEXIBLE_MIN: i16 = consumer_group_heartbeat_request::FLEXIBLE_MIN;
786 type Response = ConsumerGroupHeartbeatResponse;
787}
788
789struct HeartbeatCall<'a> {
791 broker: krabka_client_core::BrokerHandle<'a>,
792 request: ConsumerGroupHeartbeatRequest,
793 needs_regex: bool,
796}
797
798impl HeartbeatCall<'_> {
799 async fn send(self) -> Result<ConsumerGroupHeartbeatResponse, krabka_client_core::ClientError> {
800 if self.needs_regex {
801 return self.broker.send(RegexHeartbeat(self.request)).await;
802 }
803 self.broker.send(self.request).await
804 }
805}
806
807async fn refresh_topic_ids(state: &coordinator::CoordinatorState, unresolved: &HashSet<WireUuid>) {
815 let by_id = state.subscription.borrow().regex.is_some();
816 let request = if by_id {
817 MetadataRequest {
818 topics: Some(
819 unresolved
820 .iter()
821 .map(|topic_id| MetadataRequestTopic {
822 topic_id: *topic_id,
823 name: None,
824 ..Default::default()
825 })
826 .collect(),
827 ),
828 allow_auto_topic_creation: false,
829 ..Default::default()
830 }
831 } else {
832 state.client.metadata_topics().request()
833 };
834 match state.client.refresh_metadata_with(request).await {
835 Ok(metadata) => {
836 crate::validate::refresh_tracked_topic_ids(
837 &mut *state.topic_ids.lock().await,
838 &metadata,
839 );
840 }
841 Err(error) => {
842 tracing::warn!(%error, group = %state.group_id, "metadata refresh failed");
843 }
844 }
845}
846
847fn heartbeat_interval(
850 heartbeat_interval_ms: i32,
851 configured: krabka_units::Time,
852) -> std::time::Duration {
853 if heartbeat_interval_ms > 0 {
854 std::time::Duration::from_millis(u64::try_from(heartbeat_interval_ms).unwrap_or(0))
855 } else {
856 configured.to_std()
857 }
858}
859
860async fn reconcile(
867 state: &mut coordinator::CoordinatorState,
868 target: &[(String, i32)],
869) -> Result<bool, error::ConsumerError> {
870 let owned = state.assigned.lock().await.clone();
871 let target_set: HashSet<(String, i32)> = target.iter().cloned().collect();
872 let owned_set: HashSet<(String, i32)> = owned.iter().cloned().collect();
873 let mut revoked: Vec<(String, i32)> = owned
874 .iter()
875 .filter(|partition| !target_set.contains(*partition))
876 .cloned()
877 .collect();
878 let mut added: Vec<(String, i32)> = target
879 .iter()
880 .filter(|partition| !owned_set.contains(*partition))
881 .cloned()
882 .collect();
883 revoked.sort();
884 added.sort();
885 if revoked.is_empty() && added.is_empty() {
886 state.rebalance_pending.send_replace(false);
887 return Ok(false);
888 }
889 state.rebalance_pending.send_replace(true);
890 let epoch = state.member_epoch;
891 if !revoked.is_empty() {
892 coordinator::call_listener_kind(
893 state,
894 rebalance_listener::ListenerCallKind::Revoked,
895 revoked.clone(),
896 )
897 .await?;
898 let kept: Vec<(String, i32)> = owned
899 .iter()
900 .filter(|partition| target_set.contains(*partition))
901 .cloned()
902 .collect();
903 coordinator::publish_group_assignment(state, &kept, epoch).await;
904 }
905 coordinator::prime_added_offsets(state, &added).await?;
906 let gate = coordinator::assigned_callback_gate(state, &added);
907 coordinator::publish_group_assignment(state, target, epoch).await;
908 let result = coordinator::call_listener_kind(
909 state,
910 rebalance_listener::ListenerCallKind::Assigned,
911 added,
912 )
913 .await;
914 drop(gate);
915 state.rebalance_pending.send_replace(false);
916 result.map(|()| true)
917}
918
919#[cfg(test)]
920mod group_protocol_tests {
921 use std::sync::{Arc, Mutex};
922
923 use bytes::Buf as _;
924 use krabka_client_core::MockBroker;
925 use krabka_protocol::{
926 Decode as _,
927 owned::{
928 api_versions_request,
929 api_versions_response::ApiVersionsResponse,
930 common::consumer_group_heartbeat_response::topic_partitions::TopicPartitions as AssignedPartitions,
931 consumer_group_heartbeat_request,
932 consumer_group_heartbeat_response::Assignment,
933 fetch_request,
934 fetch_response::FetchResponse,
935 find_coordinator_request,
936 find_coordinator_response::FindCoordinatorResponse,
937 list_offsets_request,
938 list_offsets_response::{
939 ListOffsetsPartitionResponse, ListOffsetsResponse, ListOffsetsTopicResponse,
940 },
941 metadata_request,
942 metadata_response::{MetadataResponse, MetadataResponseTopic},
943 offset_fetch_request,
944 offset_fetch_response::{
945 OffsetFetchResponse, OffsetFetchResponsePartition, OffsetFetchResponseTopic,
946 },
947 },
948 };
949 use krabka_units::{Time, millis, secs};
950
951 use super::*;
952 use crate::{
953 CloseOptions, Consumer,
954 consumer::GroupMembershipOperation,
955 test_support::{ListenerCall, ListenerCalls, RecordingListener},
956 };
957
958 const TOPIC: &str = "orders";
959 const TOPIC_ID: WireUuid = WireUuid([3; 16]);
960
961 #[derive(Clone, Debug)]
963 struct Answer {
964 error_code: i16,
965 member_epoch: i32,
966 assignment: Option<Vec<i32>>,
969 member_id: Option<String>,
972 }
973
974 fn assigns(member_epoch: i32, partitions: &[i32]) -> Answer {
976 Answer {
977 error_code: 0,
978 member_epoch,
979 assignment: Some(partitions.to_vec()),
980 member_id: None,
981 }
982 }
983
984 fn named(answer: Answer, member_id: &str) -> Answer {
986 Answer {
987 member_id: Some(member_id.to_owned()),
988 ..answer
989 }
990 }
991
992 fn fails(error_code: i16) -> Answer {
994 Answer {
995 error_code,
996 member_epoch: 0,
997 assignment: None,
998 member_id: None,
999 }
1000 }
1001
1002 #[derive(Clone, Default)]
1004 struct Recorder {
1005 heartbeats: Arc<Mutex<Vec<ConsumerGroupHeartbeatRequest>>>,
1007 metadata_topics: Arc<Mutex<Vec<String>>>,
1009 asked_by_id: Arc<Mutex<bool>>,
1011 classic_heartbeats: Arc<Mutex<usize>>,
1014 }
1015
1016 impl Recorder {
1017 fn heartbeats(&self) -> Vec<ConsumerGroupHeartbeatRequest> {
1018 self.heartbeats.lock().expect("heartbeats lock").clone()
1019 }
1020
1021 fn acknowledged(&self) -> Vec<Vec<i32>> {
1023 acknowledged(&self.heartbeats())
1024 }
1025
1026 fn epochs(&self) -> Vec<i32> {
1027 self.heartbeats()
1028 .iter()
1029 .map(|request| request.member_epoch)
1030 .collect()
1031 }
1032
1033 fn first_heartbeat(&self) -> ConsumerGroupHeartbeatRequest {
1034 self.heartbeats()
1035 .into_iter()
1036 .next()
1037 .expect("a first heartbeat")
1038 }
1039 }
1040
1041 fn encode(response: &impl krabka_protocol::Encode, version: i16) -> Vec<u8> {
1042 let mut buffer = bytes::BytesMut::new();
1043 response.encode(&mut buffer, version).expect("encode");
1044 buffer.to_vec()
1045 }
1046
1047 fn flexible(response: &impl krabka_protocol::Encode, version: i16) -> Vec<u8> {
1050 let mut frame = vec![0];
1051 frame.extend(encode(response, version));
1052 frame
1053 }
1054
1055 fn api_versions() -> ApiVersionsResponse {
1057 krabka_client_core::mock_test_support::api_versions_response([
1058 (api_versions_request::API_KEY, 3),
1059 (metadata_request::API_KEY, 12),
1060 (find_coordinator_request::API_KEY, 2),
1061 (offset_fetch_request::API_KEY, 5),
1062 (list_offsets_request::API_KEY, 7),
1063 (fetch_request::API_KEY, 12),
1064 (consumer_group_heartbeat_request::API_KEY, 1),
1065 ])
1066 }
1067
1068 fn metadata() -> MetadataResponse {
1070 MetadataResponse {
1071 topics: vec![MetadataResponseTopic {
1072 name: Some(TOPIC.into()),
1073 topic_id: TOPIC_ID,
1074 partitions: crate::test_support::metadata_partitions(2),
1075 ..Default::default()
1076 }],
1077 ..Default::default()
1078 }
1079 }
1080
1081 fn empty_list_offset_partitions() -> Vec<ListOffsetsPartitionResponse> {
1083 (0..2)
1084 .map(|partition_index| ListOffsetsPartitionResponse {
1085 partition_index,
1086 timestamp: -1,
1087 offset: 0,
1088 leader_epoch: -1,
1089 ..Default::default()
1090 })
1091 .collect()
1092 }
1093
1094 fn list_offsets() -> ListOffsetsResponse {
1095 ListOffsetsResponse {
1096 topics: vec![ListOffsetsTopicResponse {
1097 name: TOPIC.into(),
1098 partitions: empty_list_offset_partitions(),
1099 ..Default::default()
1100 }],
1101 ..Default::default()
1102 }
1103 }
1104
1105 fn missing_committed_partitions() -> Vec<OffsetFetchResponsePartition> {
1108 (0..2)
1109 .map(|partition_index| OffsetFetchResponsePartition {
1110 partition_index,
1111 committed_offset: -1,
1112 committed_leader_epoch: -1,
1113 ..Default::default()
1114 })
1115 .collect()
1116 }
1117
1118 fn no_committed_offsets() -> OffsetFetchResponse {
1119 OffsetFetchResponse {
1120 topics: vec![OffsetFetchResponseTopic {
1121 name: TOPIC.into(),
1122 partitions: missing_committed_partitions(),
1123 ..Default::default()
1124 }],
1125 ..Default::default()
1126 }
1127 }
1128
1129 fn metadata_request_of(mut body: &[u8], version: i16) -> MetadataRequest {
1131 let client_id_len = usize::try_from(body.get_i16()).expect("client id length");
1132 body.advance(client_id_len);
1133 if version >= 9 {
1134 body.advance(1);
1135 }
1136 MetadataRequest::decode(&mut body, version).expect("decode")
1137 }
1138
1139 fn heartbeat_request(mut body: &[u8], version: i16) -> ConsumerGroupHeartbeatRequest {
1141 let client_id_len = usize::try_from(body.get_i16()).expect("client id length");
1142 body.advance(client_id_len);
1143 body.advance(1);
1145 ConsumerGroupHeartbeatRequest::decode(&mut body, version).expect("decode")
1146 }
1147
1148 async fn heartbeat_coordinator(answers: Vec<Answer>, recorder: &Recorder) -> MockBroker {
1152 heartbeat_coordinator_with(answers, recorder, false).await
1153 }
1154
1155 async fn heartbeat_coordinator_with(
1159 answers: Vec<Answer>,
1160 recorder: &Recorder,
1161 by_id_only: bool,
1162 ) -> MockBroker {
1163 let recorder = recorder.clone();
1164 let answers = Mutex::new(answers.into_iter());
1165 let last = Mutex::new(assigns(1, &[]));
1166 MockBroker::start(move |api_key, version, _corr_id, body| {
1167 if api_key == api_versions_request::API_KEY {
1168 return Some(encode(&api_versions(), 0));
1169 }
1170 if api_key == find_coordinator_request::API_KEY {
1171 return Some(encode(&FindCoordinatorResponse::default(), version));
1172 }
1173 if api_key == CLASSIC_HEARTBEAT_API_KEY {
1174 *recorder
1175 .classic_heartbeats
1176 .lock()
1177 .expect("classic heartbeats lock") += 1;
1178 return None;
1179 }
1180 if api_key == metadata_request::API_KEY {
1181 let request = metadata_request_of(body, version);
1182 let topics = request.topics.unwrap_or_default();
1183 let by_id = topics
1184 .iter()
1185 .any(|topic| topic.topic_id != WireUuid::default());
1186 if by_id {
1187 *recorder.asked_by_id.lock().expect("asked lock") = true;
1188 }
1189 recorder
1190 .metadata_topics
1191 .lock()
1192 .expect("metadata topics lock")
1193 .extend(topics.iter().filter_map(|topic| topic.name.clone()));
1194 if by_id_only && !by_id {
1195 return Some(flexible(&MetadataResponse::default(), version));
1196 }
1197 return Some(flexible(&metadata(), version));
1198 }
1199 if api_key == offset_fetch_request::API_KEY {
1200 return Some(encode(&no_committed_offsets(), version));
1201 }
1202 if api_key == list_offsets_request::API_KEY {
1203 return Some(flexible(&list_offsets(), version));
1204 }
1205 if api_key == fetch_request::API_KEY {
1206 return Some(flexible(&FetchResponse::default(), version));
1207 }
1208 if api_key != consumer_group_heartbeat_request::API_KEY {
1209 return None;
1210 }
1211 recorder
1212 .heartbeats
1213 .lock()
1214 .expect("heartbeats lock")
1215 .push(heartbeat_request(body, version));
1216 let mut last = last.lock().expect("last lock");
1217 if let Some(next) = answers.lock().expect("answers lock").next() {
1218 *last = next;
1219 }
1220 let response = ConsumerGroupHeartbeatResponse {
1221 error_code: last.error_code,
1222 member_epoch: last.member_epoch,
1223 member_id: last.member_id.clone(),
1224 heartbeat_interval_ms: 20,
1225 assignment: last.assignment.clone().map(|partitions| Assignment {
1226 topic_partitions: vec![AssignedPartitions {
1227 topic_id: TOPIC_ID,
1228 partitions,
1229 ..Default::default()
1230 }],
1231 ..Default::default()
1232 }),
1233 ..Default::default()
1234 };
1235 Some(flexible(&response, version))
1236 })
1237 .await
1238 }
1239
1240 async fn assigned_coordinator(recorder: &Recorder) -> MockBroker {
1241 heartbeat_coordinator(vec![assigns(1, &[0, 1])], recorder).await
1242 }
1243
1244 struct AssignedGroupFixture {
1245 recorder: Recorder,
1246 mock: MockBroker,
1247 }
1248
1249 async fn assigned_group_fixture() -> AssignedGroupFixture {
1250 let recorder = Recorder::default();
1251 let mock = assigned_coordinator(&recorder).await;
1252 AssignedGroupFixture { recorder, mock }
1253 }
1254
1255 const CLASSIC_HEARTBEAT_API_KEY: i16 = 12;
1257
1258 #[derive(Debug, Eq, PartialEq)]
1260 struct Observed {
1261 acknowledged: Vec<Vec<i32>>,
1265 owned: Vec<i32>,
1267 last_epoch: i32,
1269 rejoins: usize,
1271 }
1272
1273 fn acknowledged(requests: &[ConsumerGroupHeartbeatRequest]) -> Vec<Vec<i32>> {
1275 requests
1276 .iter()
1277 .filter_map(|request| request.topic_partitions.as_ref())
1278 .map(|topics| {
1279 topics
1280 .iter()
1281 .flat_map(|topic| topic.partitions.clone())
1282 .collect()
1283 })
1284 .collect()
1285 }
1286
1287 enum GroupPollTarget<'a> {
1288 Acknowledged {
1289 recorder: &'a Recorder,
1290 count: usize,
1291 },
1292 Assignment,
1293 }
1294
1295 #[derive(bon::Builder)]
1296 struct GroupPollWait<'a> {
1297 consumer: &'a mut Consumer,
1298 target: GroupPollTarget<'a>,
1299 }
1300
1301 impl GroupPollWait<'_> {
1302 async fn wait(self) {
1303 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
1304 loop {
1305 let ready = match self.target {
1306 GroupPollTarget::Acknowledged { recorder, count } => {
1307 recorder.acknowledged().len() >= count
1308 }
1309 GroupPollTarget::Assignment => !self.consumer.assignment().await.is_empty(),
1310 };
1311 if ready || tokio::time::Instant::now() >= deadline {
1312 break;
1313 }
1314 let _ = self.consumer.poll(millis(20)).await;
1315 }
1316 }
1317 }
1318
1319 async fn run_member(answers: Vec<Answer>, instance_id: Option<&str>, acks: usize) -> Observed {
1322 let recorder = Recorder::default();
1323 let mock = heartbeat_coordinator(answers, &recorder).await;
1324 let mut consumer = TestConsumerOptions::builder()
1325 .mock(&mock)
1326 .server_assignor("uniform".into())
1327 .maybe_group_instance_id(instance_id.map(str::to_owned))
1328 .build()
1329 .start()
1330 .await;
1331 GroupPollWait::builder()
1332 .consumer(&mut consumer)
1333 .target(GroupPollTarget::Acknowledged {
1334 recorder: &recorder,
1335 count: acks,
1336 })
1337 .build()
1338 .wait()
1339 .await;
1340 let owned: Vec<i32> = consumer
1341 .assignment()
1342 .await
1343 .into_iter()
1344 .map(|(_, partition)| partition)
1345 .collect();
1346 consumer
1347 .close_with(CloseOptions::group_membership_operation(
1348 GroupMembershipOperation::Default,
1349 ))
1350 .await
1351 .expect("close");
1352 mock.stop();
1353 let epochs = recorder.epochs();
1354 Observed {
1355 acknowledged: recorder.acknowledged(),
1356 owned,
1357 last_epoch: *epochs.last().expect("a last heartbeat"),
1358 rejoins: epochs
1359 .iter()
1360 .skip(1)
1361 .filter(|epoch| **epoch == JOIN_GROUP_MEMBER_EPOCH)
1362 .count(),
1363 }
1364 }
1365
1366 enum SubscriptionMode {
1367 Topics(Vec<String>),
1368 Regex(String),
1369 Pattern(crate::TopicPattern),
1370 }
1371
1372 #[derive(bon::Builder)]
1373 struct TestConsumerOptions<'a> {
1374 mock: &'a MockBroker,
1375 #[builder(default = SubscriptionMode::Topics(vec![TOPIC.to_owned()]))]
1376 subscription: SubscriptionMode,
1377 server_assignor: Option<String>,
1378 #[builder(default = millis(20))]
1379 heartbeat_interval: Time,
1380 #[builder(default = secs(5))]
1381 request_timeout: Time,
1382 max_poll_interval: Option<Time>,
1383 group_instance_id: Option<String>,
1384 rebalance_listener: Option<Box<dyn crate::ConsumerRebalanceListener>>,
1385 }
1386
1387 impl TestConsumerOptions<'_> {
1388 async fn start(self) -> Consumer {
1389 let builder = Consumer::builder()
1390 .bootstrap(self.mock.addr.to_string())
1391 .group_id("group-a")
1392 .group_protocol(GroupProtocol::Consumer)
1393 .heartbeat_interval(self.heartbeat_interval)
1394 .request_timeout(self.request_timeout)
1395 .maybe_group_remote_assignor(self.server_assignor)
1396 .maybe_group_instance_id(self.group_instance_id)
1397 .maybe_rebalance_listener(self.rebalance_listener)
1398 .maybe_max_poll_interval(self.max_poll_interval);
1399 match self.subscription {
1400 SubscriptionMode::Topics(topics) => {
1401 builder.subscribe(topics).build().await.expect("build")
1402 }
1403 SubscriptionMode::Regex(regex) => {
1404 builder.subscribe_regex(regex).build().await.expect("build")
1405 }
1406 SubscriptionMode::Pattern(pattern) => builder
1407 .subscribe_pattern(pattern)
1408 .build()
1409 .await
1410 .expect("build"),
1411 }
1412 }
1413
1414 async fn start_and_assign(self) -> Consumer {
1415 let mut consumer = self.start().await;
1416 poll_until_assigned(&mut consumer).await;
1417 consumer
1418 }
1419 }
1420
1421 async fn poll_until_assigned(consumer: &mut Consumer) {
1422 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
1423 while consumer.assignment().await.is_empty() && tokio::time::Instant::now() < deadline {
1424 let _ = consumer.poll(millis(20)).await;
1425 }
1426 }
1427
1428 async fn poll_until_error(consumer: &mut Consumer) -> Option<String> {
1429 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
1430 let mut error = None;
1431 while error.is_none() && tokio::time::Instant::now() < deadline {
1432 error = consumer
1433 .poll(millis(20))
1434 .await
1435 .err()
1436 .map(|error| error.to_string());
1437 }
1438 error
1439 }
1440
1441 async fn started_consumer_group(mock: &MockBroker) -> Consumer {
1442 TestConsumerOptions::builder()
1443 .mock(mock)
1444 .build()
1445 .start_and_assign()
1446 .await
1447 }
1448
1449 async fn close_and_first_heartbeat(
1450 consumer: Consumer,
1451 mock: MockBroker,
1452 recorder: &Recorder,
1453 ) -> ConsumerGroupHeartbeatRequest {
1454 consumer.close().await.expect("close");
1455 mock.stop();
1456 recorder.first_heartbeat()
1457 }
1458
1459 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1462 async fn the_first_heartbeat_joins_the_group_with_every_field() {
1463 let AssignedGroupFixture { recorder, mock } = assigned_group_fixture().await;
1464 let consumer = TestConsumerOptions::builder()
1465 .mock(&mock)
1466 .server_assignor("uniform".into())
1467 .build()
1468 .start_and_assign()
1469 .await;
1470 let first = close_and_first_heartbeat(consumer, mock, &recorder).await;
1471 assert2::assert!(
1472 ConsumerGroupHeartbeatRequest {
1473 member_id: String::new(),
1474 ..first.clone()
1475 } == ConsumerGroupHeartbeatRequest {
1476 group_id: "group-a".to_owned(),
1477 member_id: String::new(),
1478 member_epoch: JOIN_GROUP_MEMBER_EPOCH,
1479 rebalance_timeout_ms: 300_000,
1480 subscribed_topic_names: Some(vec![TOPIC.to_owned()]),
1481 server_assignor: Some("uniform".to_owned()),
1482 topic_partitions: Some(Vec::new()),
1483 ..Default::default()
1484 }
1485 );
1486 assert2::assert!(uuid::Uuid::parse_str(&first.member_id).is_ok());
1488 }
1489
1490 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1496 async fn a_regular_expression_subscription_resolves_its_topics_by_id() {
1497 let recorder = Recorder::default();
1498 let mock = heartbeat_coordinator_with(vec![assigns(1, &[0, 1])], &recorder, true).await;
1499 let consumer = TestConsumerOptions::builder()
1500 .mock(&mock)
1501 .subscription(SubscriptionMode::Regex("orders-.*".into()))
1502 .build()
1503 .start_and_assign()
1504 .await;
1505 let assignment = consumer.assignment().await;
1506 let subscription = consumer.subscription();
1507 let first = close_and_first_heartbeat(consumer, mock, &recorder).await;
1508 assert2::assert!(
1509 (
1510 first.subscribed_topic_regex,
1511 first.subscribed_topic_names,
1512 *recorder.asked_by_id.lock().expect("asked lock"),
1513 assignment,
1514 subscription,
1515 ) == (
1516 Some("orders-.*".to_owned()),
1517 Some(Vec::new()),
1518 true,
1519 vec![(TOPIC.to_owned(), 0), (TOPIC.to_owned(), 1)],
1520 vec![TOPIC.to_owned()],
1521 )
1522 );
1523 }
1524
1525 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1529 async fn a_client_side_pattern_sends_the_topics_that_matched() {
1530 let AssignedGroupFixture { recorder, mock } = assigned_group_fixture().await;
1531 let consumer = TestConsumerOptions::builder()
1532 .mock(&mock)
1533 .subscription(SubscriptionMode::Pattern(crate::TopicPattern::new(
1534 |topic| topic.starts_with("ord"),
1535 )))
1536 .build()
1537 .start_and_assign()
1538 .await;
1539 let assignment = consumer.assignment().await;
1540 let first = close_and_first_heartbeat(consumer, mock, &recorder).await;
1541 assert2::assert!(
1542 (
1543 first.subscribed_topic_names,
1544 first.subscribed_topic_regex,
1545 assignment,
1546 ) == (
1547 Some(vec![TOPIC.to_owned()]),
1548 None,
1549 vec![(TOPIC.to_owned(), 0), (TOPIC.to_owned(), 1)],
1550 )
1551 );
1552 }
1553
1554 fn first_lost(calls: &ListenerCalls) -> Option<ListenerCall> {
1556 calls
1557 .lock()
1558 .expect("calls lock")
1559 .iter()
1560 .find(|(kind, _)| *kind == rebalance_listener::ListenerCallKind::Lost)
1561 .cloned()
1562 }
1563
1564 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1569 async fn a_fatal_heartbeat_error_reaches_poll() {
1570 let mut actual = Vec::new();
1571 for (name, answers, speaks_heartbeat) in [
1572 (
1573 "an unsupported assignor",
1574 vec![fails(UNSUPPORTED_ASSIGNOR)],
1575 true,
1576 ),
1577 ("a broker without the api", Vec::new(), false),
1578 ] {
1579 let recorder = Recorder::default();
1580 let mock = if speaks_heartbeat {
1581 heartbeat_coordinator(answers, &recorder).await
1582 } else {
1583 classic_only_coordinator().await
1584 };
1585 let mut consumer = TestConsumerOptions::builder()
1586 .mock(&mock)
1587 .build()
1588 .start()
1589 .await;
1590 let error = poll_until_error(&mut consumer).await;
1591 consumer.close().await.expect("close");
1592 mock.stop();
1593 actual.push((name, error));
1594 }
1595 assert2::assert!(
1596 actual
1597 == vec![
1598 (
1599 "an unsupported assignor",
1600 Some("broker error_code 112".to_owned()),
1601 ),
1602 (
1603 "a broker without the api",
1604 Some(
1605 "client: incompatible version: broker supports 2..=2, client wants 0..=1 for api_key 68"
1606 .to_owned()
1607 ),
1608 ),
1609 ]
1610 );
1611 }
1612
1613 async fn classic_only_coordinator() -> MockBroker {
1616 coordinator_with_heartbeat_versions(2, 2).await
1617 }
1618
1619 async fn coordinator_with_heartbeat_versions(min_version: i16, max_version: i16) -> MockBroker {
1622 MockBroker::start(move |api_key, version, _corr_id, _body| {
1623 if api_key == api_versions_request::API_KEY {
1624 let mut versions = api_versions();
1625 for api in &mut versions.api_keys {
1626 if api.api_key == consumer_group_heartbeat_request::API_KEY {
1627 api.min_version = min_version;
1628 api.max_version = max_version;
1629 }
1630 }
1631 return Some(encode(&versions, 0));
1632 }
1633 if api_key == find_coordinator_request::API_KEY {
1634 return Some(encode(&FindCoordinatorResponse::default(), version));
1635 }
1636 if api_key == metadata_request::API_KEY {
1637 return Some(flexible(&metadata(), version));
1638 }
1639 None
1640 })
1641 .await
1642 }
1643
1644 async fn regex_consumer(mock: &MockBroker) -> Consumer {
1645 TestConsumerOptions::builder()
1646 .mock(mock)
1647 .subscription(SubscriptionMode::Regex("orders-.*".into()))
1648 .build()
1649 .start()
1650 .await
1651 }
1652
1653 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1657 async fn a_regular_expression_needs_heartbeat_version_1() {
1658 let mock = coordinator_with_heartbeat_versions(0, 0).await;
1659 let mut consumer = regex_consumer(&mock).await;
1660 let error = poll_until_error(&mut consumer).await;
1661 consumer.close().await.expect("close");
1662 mock.stop();
1663 assert2::assert!(
1664 error
1665 == Some(
1666 "client: incompatible version: broker supports 0..=0, client wants 1..=1 for api_key 68"
1667 .to_owned()
1668 )
1669 );
1670 }
1671
1672 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1676 async fn a_regular_expression_subscription_keeps_fetching_after_a_resubscribe() {
1677 let recorder = Recorder::default();
1678 let mock = heartbeat_coordinator_with(vec![assigns(1, &[0, 1])], &recorder, true).await;
1679 let mut consumer = regex_consumer(&mock).await;
1680 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
1683 while tokio::time::Instant::now() < deadline {
1684 let _ = consumer.poll(millis(20)).await;
1685 let assigned = consumer.assignment().await;
1686 if !assigned.is_empty() && !consumer.group_fetches(&assigned).await.is_empty() {
1687 break;
1688 }
1689 }
1690 consumer.subscribe_regex("orders-.*").expect("subscribe");
1691 let assigned = consumer.assignment().await;
1692 let mut fetched: Vec<(String, i32)> = consumer
1693 .group_fetches(&assigned)
1694 .await
1695 .into_values()
1696 .flatten()
1697 .flat_map(|(topic, partitions)| {
1698 partitions
1699 .into_iter()
1700 .map(move |(partition, ..)| (topic.clone(), partition))
1701 })
1702 .collect();
1703 fetched.sort();
1704 consumer.close().await.expect("close");
1705 mock.stop();
1706 assert2::assert!(
1707 (assigned, fetched)
1708 == (
1709 vec![(TOPIC.to_owned(), 0), (TOPIC.to_owned(), 1)],
1710 vec![(TOPIC.to_owned(), 0), (TOPIC.to_owned(), 1)],
1711 )
1712 );
1713 }
1714
1715 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1719 async fn unsubscribe_leaves_the_group_and_a_new_subscription_joins_again() {
1720 let recorder = Recorder::default();
1721 let mock = assigned_coordinator(&recorder).await;
1722 let mut consumer = started_consumer_group(&mock).await;
1723 consumer.unsubscribe().await.expect("unsubscribe");
1724 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
1725 while !recorder.epochs().contains(&LEAVE_GROUP_MEMBER_EPOCH)
1726 && tokio::time::Instant::now() < deadline
1727 {
1728 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1729 }
1730 let after_unsubscribe = (consumer.assignment().await, recorder.epochs());
1731 consumer
1732 .subscribe([TOPIC.to_owned()])
1733 .await
1734 .expect("subscribe");
1735 poll_until_assigned(&mut consumer).await;
1736 let joined_again = consumer.assignment().await;
1737 consumer.close().await.expect("close");
1738 mock.stop();
1739 let (left, epochs) = after_unsubscribe;
1740 let joins_after_the_leave = epochs
1741 .iter()
1742 .skip_while(|epoch| **epoch != LEAVE_GROUP_MEMBER_EPOCH)
1743 .filter(|epoch| **epoch == JOIN_GROUP_MEMBER_EPOCH)
1744 .count();
1745 assert2::assert!(
1746 (
1747 left,
1748 epochs.contains(&LEAVE_GROUP_MEMBER_EPOCH),
1749 joins_after_the_leave,
1750 joined_again
1751 ) == (
1752 Vec::new(),
1753 true,
1754 0,
1755 vec![(TOPIC.to_owned(), 0), (TOPIC.to_owned(), 1)],
1756 )
1757 );
1758 }
1759
1760 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1765 async fn a_member_that_stops_polling_leaves_and_joins_again() {
1766 let recorder = Recorder::default();
1767 let calls: ListenerCalls = Arc::default();
1768 let mock = assigned_coordinator(&recorder).await;
1769 let mut consumer = TestConsumerOptions::builder()
1770 .mock(&mock)
1771 .max_poll_interval(millis(300))
1772 .rebalance_listener(Box::new(
1773 RecordingListener::builder()
1774 .calls(Arc::clone(&calls))
1775 .build(),
1776 ))
1777 .build()
1778 .start()
1779 .await;
1780 GroupPollWait::builder()
1781 .consumer(&mut consumer)
1782 .target(GroupPollTarget::Assignment)
1783 .build()
1784 .wait()
1785 .await;
1786 tokio::time::sleep(std::time::Duration::from_millis(700)).await;
1788 let left = consumer.assignment().await;
1789 let epochs_after_leave = recorder.epochs();
1790 GroupPollWait::builder()
1791 .consumer(&mut consumer)
1792 .target(GroupPollTarget::Assignment)
1793 .build()
1794 .wait()
1795 .await;
1796 let joined_again = consumer.assignment().await;
1797 consumer.close().await.expect("close");
1798 mock.stop();
1799 let lost = first_lost(&calls);
1802 assert2::assert!(
1803 (
1804 left,
1805 epochs_after_leave.contains(&LEAVE_GROUP_MEMBER_EPOCH),
1806 joined_again,
1807 lost,
1808 ) == (
1809 Vec::new(),
1810 true,
1811 vec![(TOPIC.to_owned(), 0), (TOPIC.to_owned(), 1)],
1812 Some((rebalance_listener::ListenerCallKind::Lost, vec![0, 1])),
1813 )
1814 );
1815 }
1816
1817 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1820 async fn a_fenced_member_gives_its_partitions_to_the_lost_callback() {
1821 let recorder = Recorder::default();
1822 let calls: ListenerCalls = Arc::default();
1823 let mock = heartbeat_coordinator(
1824 vec![
1825 assigns(1, &[0, 1]),
1826 fails(FENCED_MEMBER_EPOCH),
1827 assigns(1, &[0, 1]),
1828 ],
1829 &recorder,
1830 )
1831 .await;
1832 let mut consumer = TestConsumerOptions::builder()
1833 .mock(&mock)
1834 .rebalance_listener(Box::new(
1835 RecordingListener::builder()
1836 .calls(Arc::clone(&calls))
1837 .build(),
1838 ))
1839 .build()
1840 .start()
1841 .await;
1842 let member_id = consumer.member_id();
1843 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
1844 while first_lost(&calls).is_none() && tokio::time::Instant::now() < deadline {
1845 let _ = consumer.poll(millis(20)).await;
1846 }
1847 let kept_id = consumer.member_id();
1848 consumer.close().await.expect("close");
1849 mock.stop();
1850 assert2::assert!(
1851 (first_lost(&calls), kept_id == member_id)
1852 == (
1853 Some((rebalance_listener::ListenerCallKind::Lost, vec![0, 1])),
1854 true,
1855 )
1856 );
1857 }
1858
1859 const HEARTBEATS_IN_CALLBACK: usize = 3;
1862
1863 struct HeartbeatWaitingListener {
1867 recorder: Recorder,
1868 in_callback: Arc<Mutex<Option<usize>>>,
1871 }
1872
1873 #[async_trait::async_trait]
1874 impl crate::ConsumerRebalanceListener for HeartbeatWaitingListener {
1875 async fn on_partitions_revoked(
1876 &mut self,
1877 _consumer: &Consumer,
1878 _partitions: &[(String, i32)],
1879 ) -> Result<(), crate::RebalanceListenerError> {
1880 Ok(())
1881 }
1882
1883 async fn on_partitions_assigned(
1884 &mut self,
1885 _consumer: &Consumer,
1886 _partitions: &[(String, i32)],
1887 ) -> Result<(), crate::RebalanceListenerError> {
1888 let before = self.recorder.heartbeats().len();
1889 let bound = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
1890 let mut arrived = 0;
1891 while arrived < HEARTBEATS_IN_CALLBACK && tokio::time::Instant::now() < bound {
1892 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1893 arrived = self.recorder.heartbeats().len() - before;
1894 }
1895 *self.in_callback.lock().expect("in callback lock") = Some(arrived);
1896 Ok(())
1897 }
1898 }
1899
1900 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1908 async fn heartbeats_go_on_during_a_rebalance_callback() {
1909 let recorder = Recorder::default();
1910 let in_callback: Arc<Mutex<Option<usize>>> = Arc::default();
1911 let mock = assigned_coordinator(&recorder).await;
1912 let mut consumer = TestConsumerOptions::builder()
1913 .mock(&mock)
1914 .rebalance_listener(Box::new(HeartbeatWaitingListener {
1915 recorder: recorder.clone(),
1916 in_callback: Arc::clone(&in_callback),
1917 }))
1918 .build()
1919 .start()
1920 .await;
1921 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
1922 while in_callback.lock().expect("in callback lock").is_none()
1923 && tokio::time::Instant::now() < deadline
1924 {
1925 let _ = consumer.poll(millis(200)).await;
1926 }
1927 let during_callback = *in_callback.lock().expect("in callback lock");
1928 consumer.close().await.expect("close");
1929 mock.stop();
1930 let classic = *recorder
1931 .classic_heartbeats
1932 .lock()
1933 .expect("classic heartbeats lock");
1934 assert2::assert!(
1935 (
1936 during_callback.map(|arrived| arrived >= HEARTBEATS_IN_CALLBACK),
1937 classic
1938 ) == (Some(true), 0)
1939 );
1940 }
1941
1942 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1945 async fn the_member_id_and_a_new_subscription_reach_the_consumer() {
1946 let recorder = Recorder::default();
1947 let mock =
1948 heartbeat_coordinator(vec![named(assigns(1, &[0, 1]), "server-id")], &recorder).await;
1949 let mut consumer = started_consumer_group(&mock).await;
1950 let member_id = consumer.member_id();
1951 consumer
1952 .subscribe([TOPIC.to_owned(), "payments".to_owned()])
1953 .await
1954 .expect("subscribe");
1955 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
1956 while !recorder
1957 .metadata_topics
1958 .lock()
1959 .expect("metadata topics lock")
1960 .iter()
1961 .any(|topic| topic == "payments")
1962 && tokio::time::Instant::now() < deadline
1963 {
1964 let _ = consumer.poll(millis(20)).await;
1965 }
1966 let asked_for_payments = recorder
1967 .metadata_topics
1968 .lock()
1969 .expect("metadata topics lock")
1970 .iter()
1971 .any(|topic| topic == "payments");
1972 consumer.close().await.expect("close");
1973 mock.stop();
1974 assert2::assert!((member_id, asked_for_payments) == ("server-id".to_owned(), true));
1975 }
1976
1977 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1982 async fn the_member_reconciles_and_leaves_as_kafka_does() {
1983 struct Case {
1984 name: &'static str,
1985 answers: Vec<Answer>,
1986 instance_id: Option<&'static str>,
1987 acks: usize,
1989 }
1990 let cases = [
1991 Case {
1992 name: "an assignment",
1993 answers: vec![assigns(1, &[0, 1])],
1994 instance_id: None,
1995 acks: 2,
1996 },
1997 Case {
1998 name: "an assignment that revokes a partition",
1999 answers: vec![assigns(1, &[0, 1]), assigns(2, &[0])],
2000 instance_id: None,
2001 acks: 3,
2002 },
2003 Case {
2004 name: "a fenced member",
2005 answers: vec![
2006 assigns(1, &[0, 1]),
2007 fails(FENCED_MEMBER_EPOCH),
2008 assigns(1, &[0, 1]),
2009 ],
2010 instance_id: None,
2011 acks: 4,
2012 },
2013 Case {
2014 name: "a static member",
2015 answers: vec![assigns(1, &[0, 1])],
2016 instance_id: Some("member-a"),
2017 acks: 2,
2018 },
2019 ];
2020 let mut actual = Vec::new();
2021 for case in cases {
2022 actual.push((
2023 case.name,
2024 run_member(case.answers, case.instance_id, case.acks).await,
2025 ));
2026 }
2027 assert2::assert!(
2028 actual
2029 == vec![
2030 (
2031 "an assignment",
2032 Observed {
2033 acknowledged: vec![vec![], vec![0, 1], vec![]],
2037 owned: vec![0, 1],
2038 last_epoch: LEAVE_GROUP_MEMBER_EPOCH,
2039 rejoins: 0,
2040 },
2041 ),
2042 (
2043 "an assignment that revokes a partition",
2044 Observed {
2045 acknowledged: vec![vec![], vec![0, 1], vec![0], vec![]],
2046 owned: vec![0],
2047 last_epoch: LEAVE_GROUP_MEMBER_EPOCH,
2048 rejoins: 0,
2049 },
2050 ),
2051 (
2052 "a fenced member",
2053 Observed {
2054 acknowledged: vec![vec![], vec![0, 1], vec![], vec![0, 1], vec![]],
2057 owned: vec![0, 1],
2058 last_epoch: LEAVE_GROUP_MEMBER_EPOCH,
2059 rejoins: 1,
2060 },
2061 ),
2062 (
2063 "a static member",
2064 Observed {
2065 acknowledged: vec![vec![], vec![0, 1], vec![]],
2066 owned: vec![0, 1],
2067 last_epoch: LEAVE_GROUP_STATIC_MEMBER_EPOCH,
2070 rejoins: 0,
2071 },
2072 ),
2073 ]
2074 );
2075 }
2076}
2077
2078#[cfg(test)]
2079mod tests {
2080 use consumer::GroupMembershipOperation;
2081 use krabka_protocol::owned::{
2082 common::consumer_group_heartbeat_response::topic_partitions::TopicPartitions as ResponseTopicPartitions,
2083 consumer_group_heartbeat_response::Assignment,
2084 };
2085
2086 use super::*;
2087
2088 const TOPIC_ID: WireUuid = WireUuid([7; 16]);
2089
2090 fn fields<'a>(member_epoch: i32, joining: bool, owned: OwnedByTopicId) -> HeartbeatFields<'a> {
2091 HeartbeatFields {
2092 group_id: "group-a",
2093 member_id: "member-a",
2094 member_epoch,
2095 instance_id: None,
2096 rack_id: Some("az-1"),
2097 rebalance_timeout_ms: 300_000,
2098 subscribed_topic_names: vec!["orders".to_owned()],
2099 regex: None,
2100 server_assignor: Some("uniform"),
2101 owned,
2102 joining,
2103 }
2104 }
2105
2106 #[test]
2109 fn a_heartbeat_repeats_only_the_fields_that_changed() {
2110 let mut sent = SentFields::default();
2111 let join = build_heartbeat(&fields(0, true, BTreeMap::new()), &mut sent);
2112 let steady = build_heartbeat(&fields(1, false, BTreeMap::new()), &mut sent);
2113 let owned = build_heartbeat(
2114 &fields(1, false, maplit::btreemap! {TOPIC_ID.0 => vec![0, 1]}),
2115 &mut sent,
2116 );
2117 assert2::assert!(
2118 (
2119 join.rack_id.clone(),
2120 join.rebalance_timeout_ms,
2121 join.subscribed_topic_names.clone(),
2122 join.server_assignor.clone(),
2123 join.topic_partitions.clone(),
2124 ) == (
2125 Some("az-1".to_owned()),
2126 300_000,
2127 Some(vec!["orders".to_owned()]),
2128 Some("uniform".to_owned()),
2129 Some(Vec::new()),
2130 )
2131 );
2132 assert2::assert!(
2133 (
2134 steady.rack_id,
2135 steady.rebalance_timeout_ms,
2136 steady.subscribed_topic_names,
2137 steady.server_assignor,
2138 steady.topic_partitions,
2139 ) == (None, -1, None, None, None)
2140 );
2141 assert2::assert!(
2142 owned.topic_partitions
2143 == Some(vec![TopicPartitions {
2144 topic_id: TOPIC_ID,
2145 partitions: vec![0, 1],
2146 ..Default::default()
2147 }])
2148 );
2149 }
2150
2151 #[test]
2155 fn a_heartbeat_carries_the_regular_expression_of_the_subscription() {
2156 let with_regex = |regex: Option<&'static str>, joining: bool| HeartbeatFields {
2157 subscribed_topic_names: Vec::new(),
2158 regex: regex.map(str::to_owned),
2159 ..fields(i32::from(!joining), joining, BTreeMap::new())
2160 };
2161 let mut sent = SentFields::default();
2162 let join = build_heartbeat(&with_regex(Some("orders-.*"), true), &mut sent);
2163 let steady = build_heartbeat(&with_regex(Some("orders-.*"), false), &mut sent);
2164 let changed = build_heartbeat(&with_regex(Some("orders-eu-.*"), false), &mut sent);
2165 let removed = build_heartbeat(&with_regex(None, false), &mut sent);
2166 let actual = [join, steady, changed, removed].map(|request| {
2167 (
2168 request.subscribed_topic_regex,
2169 request.subscribed_topic_names,
2170 )
2171 });
2172 assert2::assert!(
2173 actual
2174 == [
2175 (Some("orders-.*".to_owned()), Some(Vec::new())),
2176 (None, None),
2177 (Some("orders-eu-.*".to_owned()), None),
2178 (Some(String::new()), None),
2179 ]
2180 );
2181 }
2182
2183 #[test]
2185 fn heartbeat_errors_follow_kafkas_classes() {
2186 let actual = [0, 14, 15, 16, 25, 110, 111, 112, 81, 42, 35, 30].map(heartbeat_action);
2187 assert2::assert!(
2188 actual
2189 == [
2190 HeartbeatAction::Ok,
2191 HeartbeatAction::Retry,
2192 HeartbeatAction::FindCoordinator,
2193 HeartbeatAction::FindCoordinator,
2194 HeartbeatAction::Fenced,
2195 HeartbeatAction::Fenced,
2196 HeartbeatAction::Fatal,
2197 HeartbeatAction::Fatal,
2198 HeartbeatAction::Fatal,
2199 HeartbeatAction::Fatal,
2200 HeartbeatAction::Fatal,
2201 HeartbeatAction::Fatal,
2202 ]
2203 );
2204 }
2205
2206 #[test]
2208 fn a_leaving_member_follows_kafkas_epochs() {
2209 let mut actual = Vec::new();
2210 let mut wanted = Vec::new();
2211 for (name, instance_id, operation, expected) in [
2212 (
2213 "dynamic, default",
2214 None,
2215 GroupMembershipOperation::Default,
2216 (-1, true),
2217 ),
2218 (
2219 "static, default",
2220 Some("i-1"),
2221 GroupMembershipOperation::Default,
2222 (-2, true),
2223 ),
2224 (
2225 "static, leave group",
2226 Some("i-1"),
2227 GroupMembershipOperation::LeaveGroup,
2228 (-1, true),
2229 ),
2230 (
2231 "dynamic, remain in group",
2232 None,
2233 GroupMembershipOperation::RemainInGroup,
2234 (-1, false),
2235 ),
2236 (
2237 "static, remain in group",
2238 Some("i-1"),
2239 GroupMembershipOperation::RemainInGroup,
2240 (-2, true),
2241 ),
2242 ] {
2243 actual.push((
2244 name,
2245 (
2246 leave_group_epoch(instance_id, operation),
2247 should_send_leave_heartbeat("member-a", instance_id, operation),
2248 ),
2249 ));
2250 wanted.push((name, expected));
2251 }
2252 assert2::assert!(actual == wanted);
2253 }
2254
2255 #[test]
2258 fn an_assignment_resolves_the_topic_ids_of_the_metadata() {
2259 let other = WireUuid([9; 16]);
2260 let response = ConsumerGroupHeartbeatResponse {
2261 assignment: Some(Assignment {
2262 topic_partitions: vec![
2263 ResponseTopicPartitions {
2264 topic_id: TOPIC_ID,
2265 partitions: vec![1, 0],
2266 ..Default::default()
2267 },
2268 ResponseTopicPartitions {
2269 topic_id: other,
2270 partitions: vec![0],
2271 ..Default::default()
2272 },
2273 ],
2274 ..Default::default()
2275 }),
2276 ..Default::default()
2277 };
2278 let names = maplit::hashmap! {TOPIC_ID => "orders".to_owned()};
2279 assert2::assert!(
2280 assignment_partitions(&response, &names)
2281 == Some((
2282 vec![("orders".to_owned(), 0), ("orders".to_owned(), 1)],
2283 maplit::hashset! {other}
2284 ))
2285 );
2286 assert2::assert!(
2287 assignment_partitions(&ConsumerGroupHeartbeatResponse::default(), &names) == None
2288 );
2289 }
2290}