Skip to main content

krabka_client_consumer/
consumer_group.rs

1//! The consumer group protocol of KIP-848: `ConsumerGroupHeartbeat`.
2//!
3//! With `group_protocol(GroupProtocol::Consumer)` the member sends
4//! `ConsumerGroupHeartbeat` instead of `JoinGroup`, `SyncGroup` and
5//! `Heartbeat`. The group coordinator assigns the partitions and the member
6//! reconciles them: it gives up the partitions that the assignment no longer
7//! holds, takes the new ones, and acknowledges the assignment with the next
8//! heartbeat. This is Kafka's `ConsumerHeartbeatRequestManager` and
9//! `ConsumerMembershipManager`.
10
11use 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/// The group protocol of a consumer. Kafka's `group.protocol`.
27#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
28pub enum GroupProtocol {
29    /// `JoinGroup`, `SyncGroup` and `Heartbeat`, with client-side assignment.
30    #[default]
31    Classic,
32    /// `ConsumerGroupHeartbeat` with server-side assignment (KIP-848).
33    Consumer,
34}
35
36/// Kafka's `ConsumerGroupHeartbeatRequest.JOIN_GROUP_MEMBER_EPOCH`.
37pub(crate) const JOIN_GROUP_MEMBER_EPOCH: i32 = 0;
38/// Kafka's `ConsumerGroupHeartbeatRequest.LEAVE_GROUP_MEMBER_EPOCH`.
39pub(crate) const LEAVE_GROUP_MEMBER_EPOCH: i32 = -1;
40/// Kafka's `ConsumerGroupHeartbeatRequest.LEAVE_GROUP_STATIC_MEMBER_EPOCH`.
41pub(crate) const LEAVE_GROUP_STATIC_MEMBER_EPOCH: i32 = -2;
42
43/// `FENCED_MEMBER_EPOCH`.
44pub(crate) const FENCED_MEMBER_EPOCH: i16 = 110;
45/// `UNKNOWN_MEMBER_ID`.
46const UNKNOWN_MEMBER_ID: i16 = 25;
47/// `UNSUPPORTED_ASSIGNOR`.
48#[cfg(test)]
49const UNSUPPORTED_ASSIGNOR: i16 = 112;
50
51/// Kafka's message for a regular expression subscription of a classic member:
52/// `ClassicKafkaConsumer.subscribe(SubscriptionPattern)`.
53pub(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
55/// The partitions of an assignment, and the topic ids that the metadata does
56/// not name yet.
57pub(crate) type ResolvedAssignment = (Vec<(String, i32)>, HashSet<WireUuid>);
58
59/// The partitions that a member owns, by topic id and sorted. The wire uuid
60/// has no order, so the map is keyed by its bytes.
61pub(crate) type OwnedByTopicId = BTreeMap<[u8; 16], Vec<i32>>;
62
63/// The fields of the last `ConsumerGroupHeartbeat`. Kafka's
64/// `ConsumerHeartbeatRequestManager.HeartbeatState.SentFields`: a field goes
65/// out again only when it changed, or when the member joins.
66#[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/// What the member sends in its next heartbeat.
76#[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    /// The subscribed topics, sorted. A regular expression subscription sends
85    /// none, because the coordinator matches the expression.
86    pub subscribed_topic_names: Vec<String>,
87    /// The regular expression of a broker-side pattern subscription.
88    pub regex: Option<String>,
89    pub server_assignor: Option<&'a str>,
90    /// The partitions that the member owns, by topic id.
91    pub owned: OwnedByTopicId,
92    /// `true` for the first heartbeat of a join, which sends every field.
93    pub joining: bool,
94}
95
96/// Build the next `ConsumerGroupHeartbeat` and record the fields that it
97/// carries.
98pub(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        // Kafka sends the rack only when the member joins.
112        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    // Kafka sends an empty expression to remove a pattern subscription, and
126    // sends the expression again only when it changed or when the member
127    // joins with one.
128    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/// What the member does with the error code of a heartbeat response. Kafka's
157/// `HeartbeatRequestManager.onErrorResponse`.
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
159pub(crate) enum HeartbeatAction {
160    /// The response carries the member state.
161    Ok,
162    /// Send the heartbeat again after the retry backoff.
163    Retry,
164    /// Find the coordinator again, then send the heartbeat again.
165    FindCoordinator,
166    /// The coordinator does not know the member any more: give up the
167    /// partitions and join again with epoch 0.
168    Fenced,
169    /// The application must see the error.
170    Fatal,
171}
172
173/// Map the error code of a `ConsumerGroupHeartbeat` response, as Kafka's
174/// `ConsumerHeartbeatRequestManager` does.
175///
176/// `COORDINATOR_NOT_AVAILABLE (15)` and `NOT_COORDINATOR (16)` make the member
177/// find the coordinator again. `COORDINATOR_LOAD_IN_PROGRESS (14)` retries.
178/// `FENCED_MEMBER_EPOCH (110)` and `UNKNOWN_MEMBER_ID (25)` make the member
179/// join again from epoch 0. `UNSUPPORTED_ASSIGNOR (112)`,
180/// `UNRELEASED_INSTANCE_ID (111)`, `GROUP_MAX_SIZE_REACHED (81)`,
181/// `INVALID_REQUEST (42)`, `UNSUPPORTED_VERSION (35)` and the authorization
182/// codes are fatal.
183pub(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        // `UNSUPPORTED_ASSIGNOR (112)`, `UNRELEASED_INSTANCE_ID (111)`,
192        // `GROUP_MAX_SIZE_REACHED (81)`, `INVALID_REQUEST (42)`,
193        // `UNSUPPORTED_VERSION (35)`, the authorization codes and every other
194        // code reach the application.
195        _ => HeartbeatAction::Fatal,
196    }
197}
198
199/// The epoch of the heartbeat that leaves the group. Kafka's
200/// `ConsumerMembershipManager.leaveGroupEpoch`: a static member that closes
201/// with the default operation keeps its place in the group with `-2`.
202pub(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
216/// Whether a closing member sends the heartbeat that leaves the group. Kafka's
217/// `ConsumerHeartbeatRequestManager.shouldSendLeaveHeartbeatNow`: a dynamic
218/// member that closes with `REMAIN_IN_GROUP` sends none and waits for the
219/// session timeout.
220pub(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
231/// The partitions of an assignment, by topic name.
232///
233/// A topic id that the metadata does not name yet stays unresolved, and the
234/// member reconciles it after the next metadata refresh, as Kafka's
235/// `AbstractMembershipManager` keeps unresolved assignments.
236pub(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
260/// The owned partitions by topic id, for the next heartbeat.
261pub(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
277/// Run the member of a consumer group with the KIP-848 protocol.
278///
279/// The task sends a `ConsumerGroupHeartbeat` at once and then each heartbeat
280/// interval that the coordinator returns. Each response can carry a new
281/// assignment, which the task reconciles before the next heartbeat
282/// acknowledges it. Kafka's `ConsumerHeartbeatRequestManager` and
283/// `ConsumerMembershipManager` do the same.
284pub(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    // Kafka's `Heartbeat.pollTimer`: `poll` resets it, and the member leaves
292    // the group when it expires.
293    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    // `true` after the poll timer expired: the member left the group and waits
298    // for a `poll` before it joins again. Kafka's `MemberState.STALE`.
299    let mut left_on_poll_timeout = false;
300    // `true` after `unsubscribe`: the member left the group and waits for a
301    // new subscription. Kafka's `AbstractMembershipManager.leaveGroup`.
302    let mut unsubscribed = false;
303    // `true` after a fatal error: Kafka's `transitionToSendingLeaveGroup`
304    // sends no leave heartbeat in the fatal state.
305    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                    // The `poll` came back: join the group again, as Kafka's
341                    // member leaves the stale state when the poll timer is
342                    // reset.
343                    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                        // A new subscription joins the group again.
365                        unsubscribed = false;
366                        state.member_epoch = JOIN_GROUP_MEMBER_EPOCH;
367                        state.sent_heartbeat_fields = SentFields::default();
368                    }
369                    // The task has its own client. Its metadata requests name
370                    // the topics of the new subscription, so it can resolve
371                    // the topic ids of the next assignment.
372                    if !subscription.topics.is_empty() {
373                        state.client.metadata_topics().set(subscription.topics);
374                    }
375                }
376                TaskEvent::Heartbeat => {}
377            }
378        }
379        // Kafka's `AsyncKafkaConsumer.updateAssignmentMetadataIfNeeded` matches
380        // a client-side pattern against the metadata in each poll. The member
381        // sends the topics that matched, and the coordinator assigns them.
382        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                // Kafka acknowledges a new assignment with the next heartbeat,
404                // which it sends at once.
405                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    // A member that already left the group, or that stopped for a fatal
422    // error, sends no leave heartbeat. Kafka's
423    // `AbstractMembershipManager.leaveGroup` returns at once when the member
424    // is not in the group.
425    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/// What wakes the heartbeat task.
443#[derive(Clone, Copy, Debug, Eq, PartialEq)]
444enum TaskEvent {
445    /// The heartbeat interval elapsed.
446    Heartbeat,
447    /// A `poll` came: reset the poll timer.
448    Poll,
449    /// The subscription changed.
450    Subscription,
451    /// The poll timer expired: leave the group.
452    PollTimeout,
453}
454
455/// Leave the group because the time between two `poll` calls was longer than
456/// `max_poll_interval`, and give up every partition.
457///
458/// Kafka's `AbstractHeartbeatRequestManager.poll` calls
459/// `transitionToSendingLeaveGroup(true)` for an expired poll timer, sends the
460/// leave heartbeat and then makes the member stale until the next `poll`.
461async 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
475/// Leave the group because the application unsubscribed. Kafka's
476/// `AbstractMembershipManager.leaveGroup`: the member sends a heartbeat with
477/// its leave epoch and gives up its partitions. The `Consumer` already ran the
478/// revoke callback and cleared the assignment.
479async 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
497/// Keep the member in the group while `poll` runs a rebalance listener
498/// callback. Kafka's heartbeat manager keeps sending `ConsumerGroupHeartbeat`
499/// while the application thread runs the callback.
500///
501/// The request carries only the fields that changed, so it repeats the member
502/// state and asks for nothing new. The heartbeat after the callback acts on
503/// the response of the member state.
504pub(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/// What the task does after one heartbeat.
540#[derive(Clone, Copy, Debug, Eq, PartialEq)]
541enum HeartbeatOutcome {
542    /// Wait this interval for the next heartbeat.
543    Interval(std::time::Duration),
544    /// A new assignment was reconciled: acknowledge it at once, then wait this
545    /// interval.
546    Reconciled(std::time::Duration),
547    /// Wait the retry backoff, then send again.
548    Backoff,
549    /// Stop the task.
550    Stop,
551}
552
553/// Send one heartbeat and act on the response.
554async 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            // The member does not know what reached the coordinator, so the
565            // next heartbeat sends every field again. Kafka's
566            // `ConsumerHeartbeatRequestManager.resetHeartbeatState`.
567            state.sent_heartbeat_fields = SentFields::default();
568            if !coordinator::is_retriable_transport_error(&error) {
569                // Kafka's `AbstractHeartbeatRequestManager.onFailure` retries
570                // only a `RetriableException`, and hands every other failure
571                // to the application. A broker without api key 68 fails here.
572                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        // Kafka's `AbstractHeartbeatRequestManager.onErrorResponse` resets the
594        // sent fields, so the heartbeat after an error carries them again.
595        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            // Kafka's `transitionToFenced` gives the partitions to
613            // `onPartitionsLost` before the member joins again.
614            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        // The assignment names its topics by id, so the member needs the ids
645        // of the metadata. Kafka's `Metadata` holds them for the consumer, and
646        // `ConsumerMembershipManager` keeps an unresolved assignment until a
647        // metadata update names its topics.
648        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    // The topics of a regular expression subscription come from the
662    // assignment, because the coordinator matches the expression. They give
663    // `poll` the topics of its metadata and its fetch filter.
664    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        // Kafka acknowledges only a reconciliation that moved partitions. An
670        // assignment that repeats what the member owns goes out again with the
671        // next interval heartbeat.
672        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/// Which heartbeat the member sends. Kafka sends every field only while the
682/// member joins (`MemberState.JOINING`), and a member that leaves gives up its
683/// assignment before the request
684/// (`AbstractMembershipManager.transitionToSendingLeaveGroup` sets
685/// `currentAssignment` to `NONE`), so the leave heartbeat carries an empty
686/// partition list.
687#[derive(Clone, Copy, Debug, Eq, PartialEq)]
688enum HeartbeatKind {
689    /// The first heartbeat of a member that joins the group.
690    Join,
691    /// A heartbeat of a member of the group.
692    Interval,
693    /// The heartbeat that leaves the group.
694    Leave,
695}
696
697/// Send the heartbeat of the current member state.
698///
699/// The fields that the request carried stay in `sent_heartbeat_fields`, so the
700/// next heartbeat sends only what changed. Kafka's
701/// `ConsumerHeartbeatRequestManager.HeartbeatState.buildRequestData` keeps them
702/// in the same way.
703async 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
723/// The heartbeat fields of the current member state.
724async 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        // A regular expression subscription names no topics: Kafka's
737        // `SubscriptionState.subscription()` is empty for
738        // `AUTO_PATTERN_RE2J`, and the coordinator matches the expression.
739        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
761/// A `ConsumerGroupHeartbeat` that carries `subscribed_topic_regex`. The
762/// field exists from version 1, so a broker that speaks only version 0 must
763/// fail the call in place of dropping the expression.
764struct 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
789/// One `ConsumerGroupHeartbeat` that is ready to go out.
790struct HeartbeatCall<'a> {
791    broker: krabka_client_core::BrokerHandle<'a>,
792    request: ConsumerGroupHeartbeatRequest,
793    /// `true` when the member subscribes to a regular expression, which needs
794    /// version 1.
795    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
807/// Store the topic ids of the metadata, so the member can resolve the topics
808/// of its assignment.
809///
810/// A regular expression subscription knows no topic names, so the request
811/// names the `unresolved` topic ids. Kafka's
812/// `ConsumerMetadata.newMetadataRequestBuilder` asks for the assigned topic
813/// ids of such a subscription, and for the topic names of every other one.
814async 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
847/// The heartbeat interval of a response, or the configured one when the
848/// response names none.
849fn 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
860/// Reconcile `target` with the partitions that the member owns. Returns
861/// `true` when the member gave up or took a partition.
862///
863/// Kafka's `AbstractMembershipManager.reconcile` gives up the partitions that
864/// the assignment no longer holds, with `onPartitionsRevoked`, and then takes
865/// the new ones with `onPartitionsAssigned`.
866async 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    /// One `ConsumerGroupHeartbeat` answer of the mock coordinator.
962    #[derive(Clone, Debug)]
963    struct Answer {
964        error_code: i16,
965        member_epoch: i32,
966        /// The assigned partitions of `orders`, or `None` for a response
967        /// without an assignment.
968        assignment: Option<Vec<i32>>,
969        /// The member id of the response. The coordinator can name another one
970        /// than the request.
971        member_id: Option<String>,
972    }
973
974    /// An answer that assigns `partitions` at `member_epoch`.
975    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    /// `answer` with the member id that the coordinator names.
985    fn named(answer: Answer, member_id: &str) -> Answer {
986        Answer {
987            member_id: Some(member_id.to_owned()),
988            ..answer
989        }
990    }
991
992    /// An answer that fails with `error_code`.
993    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    /// What the mock coordinator recorded.
1003    #[derive(Clone, Default)]
1004    struct Recorder {
1005        /// Each `ConsumerGroupHeartbeat` that the mock decoded.
1006        heartbeats: Arc<Mutex<Vec<ConsumerGroupHeartbeatRequest>>>,
1007        /// The topic names of each `Metadata` request.
1008        metadata_topics: Arc<Mutex<Vec<String>>>,
1009        /// Whether a `Metadata` request named a topic by id.
1010        asked_by_id: Arc<Mutex<bool>>,
1011        /// The classic `Heartbeat` requests. A member of the consumer group
1012        /// protocol sends none.
1013        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        /// The owned partitions that each heartbeat carried.
1022        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    /// The response of a flexible api, after the tagged fields of its header.
1048    /// `ApiVersions` keeps the header of version 0, as Kafka does.
1049    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    /// The `ApiVersions` of a coordinator that speaks `ConsumerGroupHeartbeat`.
1056    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    /// The metadata of `orders`, which names the topic id of the assignment.
1069    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    /// The log start and end of `orders`: both partitions are empty.
1082    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    /// The committed offsets of the group: `orders` has none, so the member
1106    /// starts its partitions at the `auto_offset_reset`.
1107    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    /// The `Metadata` request of a frame, without its header.
1130    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    /// The `ConsumerGroupHeartbeat` request of a frame, without its header.
1140    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        // The flexible request header ends with its tagged fields.
1144        body.advance(1);
1145        ConsumerGroupHeartbeatRequest::decode(&mut body, version).expect("decode")
1146    }
1147
1148    /// A coordinator that answers each `ConsumerGroupHeartbeat` with the next
1149    /// entry of `answers`, repeats the last entry after that, and records every
1150    /// request that it decoded.
1151    async fn heartbeat_coordinator(answers: Vec<Answer>, recorder: &Recorder) -> MockBroker {
1152        heartbeat_coordinator_with(answers, recorder, false).await
1153    }
1154
1155    /// The same coordinator. With `by_id_only` it names the topic only for a
1156    /// `Metadata` request that asks for its id, as a broker does for a group
1157    /// with a broker-side pattern subscription.
1158    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    /// Kafka's `Heartbeat` api key, which the classic protocol uses.
1256    const CLASSIC_HEARTBEAT_API_KEY: i16 = 12;
1257
1258    /// What one case observed of the member.
1259    #[derive(Debug, Eq, PartialEq)]
1260    struct Observed {
1261        /// The owned partitions that the heartbeats carried. A heartbeat that
1262        /// repeats the field of the heartbeat before it carries none, so the
1263        /// list holds one entry for each change.
1264        acknowledged: Vec<Vec<i32>>,
1265        /// The partitions of `orders` that the member owned before it closed.
1266        owned: Vec<i32>,
1267        /// The member epoch of the last heartbeat.
1268        last_epoch: i32,
1269        /// How many heartbeats after the first one joined the group again.
1270        rejoins: usize,
1271    }
1272
1273    /// The owned partitions that each heartbeat of `requests` carried.
1274    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    /// Run one member against `answers` until its heartbeats carried `acks`
1320    /// partition lists, then close it.
1321    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    /// The first heartbeat of a member joins with epoch 0 and carries every
1460    /// field: Kafka's `ConsumerHeartbeatRequestManager.HeartbeatState`.
1461    #[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        // Kafka's `AsyncKafkaConsumer` generates a v4 uuid for the member id.
1487        assert2::assert!(uuid::Uuid::parse_str(&first.member_id).is_ok());
1488    }
1489
1490    /// A broker-side pattern subscription sends the regular expression and no
1491    /// topic names, and the member learns the topic of its assignment from a
1492    /// metadata request that names the topic id. Kafka's
1493    /// `subscribe(SubscriptionPattern)` and
1494    /// `ConsumerMetadata.newMetadataRequestBuilder`.
1495    #[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    /// A client-side pattern subscription matches the metadata itself, and the
1526    /// member sends the topics that matched. Kafka's
1527    /// `AsyncKafkaConsumer.updateAssignmentMetadataIfNeeded`.
1528    #[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    /// The first lost callback of `calls`.
1555    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    /// A fatal heartbeat response and a heartbeat that cannot go out both end
1565    /// the member and reach the application through `poll`. Kafka's
1566    /// `AbstractHeartbeatRequestManager.onErrorResponse` and `onFailure` hand
1567    /// such an error to the application thread.
1568    #[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    /// A broker whose `ConsumerGroupHeartbeat` versions do not overlap the
1614    /// ones of the client.
1615    async fn classic_only_coordinator() -> MockBroker {
1616        coordinator_with_heartbeat_versions(2, 2).await
1617    }
1618
1619    /// A coordinator that speaks `ConsumerGroupHeartbeat` in this version
1620    /// range and answers no heartbeat.
1621    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    /// A regular expression subscription needs `ConsumerGroupHeartbeat` v1,
1654    /// which added `subscribed_topic_regex`. A coordinator that speaks only
1655    /// v0 fails the call, because a v0 request would name no topic at all.
1656    #[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    /// The member fetches the partitions of a regular expression assignment
1673    /// also after the application subscribes to the same expression again,
1674    /// because the topics of such a subscription come from the assignment.
1675    #[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        // Poll until the member owns the partitions and their positions are
1681        // ready, so that the fetch of the next poll would go out.
1682        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    /// `unsubscribe` makes the member leave the group with its leave epoch and
1716    /// give up its partitions, and a new `subscribe` joins the group again.
1717    /// Kafka's `AbstractMembershipManager.leaveGroup`.
1718    #[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    /// The member leaves the group when the time between two `poll` calls is
1761    /// longer than `max_poll_interval`, gives its partitions to the lost
1762    /// callback, and joins again with the next `poll`. Kafka's
1763    /// `AbstractHeartbeatRequestManager.poll` and `transitionToStale`.
1764    #[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        // The application stops polling for longer than `max_poll_interval`.
1787        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        // A `poll` that ends before its callback ran makes the next `poll`
1800        // run the callback again, so the list can hold more than one call.
1801        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    /// A fenced member gives its partitions to the lost callback, and the
1818    /// member keeps its id. Kafka's `transitionToFenced`.
1819    #[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    /// The heartbeats that reach the coordinator while the assign callback of
1860    /// [`HeartbeatWaitingListener`] runs.
1861    const HEARTBEATS_IN_CALLBACK: usize = 3;
1862
1863    /// A rebalance listener whose assign callback returns only after
1864    /// [`HEARTBEATS_IN_CALLBACK`] more heartbeats reached the coordinator, or
1865    /// after a bound. It records how many arrived.
1866    struct HeartbeatWaitingListener {
1867        recorder: Recorder,
1868        /// The heartbeats that arrived during the assign callback, once it
1869        /// returned.
1870        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    /// The member keeps its membership with `ConsumerGroupHeartbeat` while a
1901    /// rebalance listener callback runs, and never sends the classic
1902    /// `Heartbeat`.
1903    ///
1904    /// The coordinator task publishes the assignment before it queues the
1905    /// assign callback, so a non-empty `assignment()` does not mean that the
1906    /// callback ran. The test waits for the callback itself.
1907    #[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    /// The member id of the coordinator reaches the consumer, and a new
1943    /// subscription reaches the metadata of the heartbeat task.
1944    #[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    /// The member reconciles each assignment of the coordinator, acknowledges
1978    /// it with the partitions that it owns, gives up every partition when the
1979    /// coordinator fences it, and leaves the group with the epoch of its
1980    /// membership.
1981    #[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            /// How many partition lists the heartbeats carry before the close.
1988            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                            // The join carries no partitions, the next
2034                            // heartbeat acknowledges the assignment, and the
2035                            // heartbeat that leaves gives the partitions up.
2036                            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                            // The fence releases the partitions, and the member
2055                            // joins again with epoch 0 and no partitions.
2056                            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                            // A static member that closes with the default
2068                            // operation keeps its place in the group.
2069                            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    /// Kafka's `HeartbeatState.buildRequestData` sends every field when the
2107    /// member joins, and after that only the fields that changed.
2108    #[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    /// Kafka's `HeartbeatState.buildRequestData` sends the regular expression
2152    /// of a broker-side pattern subscription when the member joins with one
2153    /// and when it changed, and sends an empty expression to remove it.
2154    #[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    /// Kafka's heartbeat error classes.
2184    #[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    /// Kafka's `leaveGroupEpoch` and `shouldSendLeaveHeartbeatNow`.
2207    #[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    /// The assignment names its topics by id. An id that the metadata does not
2256    /// name yet stays unresolved.
2257    #[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}