Skip to main content

krabka_client_consumer/
commit.rs

1//! `Consumer::commit_sync` and `commit_async`.
2
3use std::{
4    collections::{BTreeSet, HashMap, HashSet},
5    future::Future,
6    mem, panic,
7    sync::{
8        Arc, Mutex as StdMutex, PoisonError,
9        atomic::{AtomicI32, Ordering},
10    },
11    time::Duration,
12};
13
14use convert::TimeExt as _;
15use krabka_client_core::ClientError;
16use krabka_protocol::owned::{
17    offset_commit_request::{OffsetCommitRequest, OffsetCommitRequestTopic},
18    offset_commit_response::OffsetCommitResponse,
19};
20use krabka_units::convert;
21use tokio::{
22    sync::Mutex,
23    task::JoinHandle,
24    time::{Instant, sleep, sleep_until, timeout_at},
25};
26use tracing::field;
27
28#[cfg(test)]
29use crate::test_support;
30use crate::{
31    consumer::{CommitIdentity, Consumer},
32    coordinator,
33    coordinator::{
34        COORDINATOR_LOAD_IN_PROGRESS, COORDINATOR_NOT_AVAILABLE, NOT_COORDINATOR, find_coordinator,
35        is_retriable_coordinator_code, is_retriable_transport_error, next_backoff,
36    },
37    error::ConsumerError,
38    offset_wire::{TopicNameOffsetCommit, build_commit_topics},
39    poll::is_reset_sentinel,
40    position::PartitionPosition,
41};
42
43const ASYNC_COMMIT_IDLE: u8 = 0;
44const ASYNC_COMMIT_RUNNING: u8 = 1;
45const ASYNC_COMMIT_DIRTY: u8 = 2;
46
47/// `UNKNOWN_TOPIC_OR_PARTITION`: the coordinator does not know the topic.
48const UNKNOWN_TOPIC_OR_PARTITION: i16 = 3;
49/// `REQUEST_TIMED_OUT`.
50const REQUEST_TIMED_OUT: i16 = 7;
51/// `ILLEGAL_GENERATION`.
52const ILLEGAL_GENERATION: i16 = 22;
53/// `UNKNOWN_MEMBER_ID`.
54const UNKNOWN_MEMBER_ID: i16 = 25;
55/// `REBALANCE_IN_PROGRESS`.
56const REBALANCE_IN_PROGRESS: i16 = 27;
57/// `TOPIC_AUTHORIZATION_FAILED`.
58const TOPIC_AUTHORIZATION_FAILED: i16 = 29;
59/// `GROUP_AUTHORIZATION_FAILED`.
60const GROUP_AUTHORIZATION_FAILED: i16 = 30;
61/// `FENCED_INSTANCE_ID`.
62const FENCED_INSTANCE_ID: i16 = 82;
63
64/// The offset that a commit stores for one partition. Kafka's
65/// `OffsetAndMetadata`.
66#[derive(Clone, Debug, Default, PartialEq, Eq)]
67pub struct OffsetAndMetadata {
68    /// The next offset to read, that is the last consumed offset plus 1.
69    pub offset: i64,
70    /// The leader epoch of the last consumed record, if known.
71    pub leader_epoch: Option<i32>,
72    /// A string that the coordinator stores with the offset. Kafka's default
73    /// is the empty string.
74    pub metadata: String,
75}
76
77impl OffsetAndMetadata {
78    /// An offset with no leader epoch and empty metadata.
79    #[must_use]
80    pub fn new(offset: i64) -> Self {
81        Self {
82            offset,
83            leader_epoch: None,
84            metadata: String::new(),
85        }
86    }
87
88    /// The commit of a fetch position with its raw wire leader epoch. `-1`
89    /// means no known epoch.
90    fn of_position(offset: i64, leader_epoch: i32) -> Self {
91        Self {
92            offset,
93            leader_epoch: (leader_epoch >= 0).then_some(leader_epoch),
94            metadata: String::new(),
95        }
96    }
97}
98
99/// The commits of fetch positions, as `(offset, leader_epoch)` pairs.
100pub fn position_commits(
101    offsets: HashMap<(String, i32), (i64, i32)>,
102) -> HashMap<(String, i32), OffsetAndMetadata> {
103    offsets
104        .into_iter()
105        .map(|(partition, (offset, leader_epoch))| {
106            (
107                partition,
108                OffsetAndMetadata::of_position(offset, leader_epoch),
109            )
110        })
111        .collect()
112}
113
114fn commit_offsets(
115    raw_offsets: HashMap<(String, i32), i64>,
116    positions: &HashMap<(String, i32), PartitionPosition>,
117) -> HashMap<(String, i32), (i64, i32)> {
118    raw_offsets
119        .into_iter()
120        .map(|(k, v)| {
121            // Unwrap the position's leader epoch to raw wire `int32` for the
122            // OffsetCommit `committed_leader_epoch` field.
123            let epoch = positions.get(&k).map_or(-1, |p| p.offset_epoch.get());
124            (k, (v, epoch))
125        })
126        .collect()
127}
128
129/// Raise each position in `polled` to the one in `sent` of a newer ownership
130/// or a higher offset. [`AutoCommit::record_sent`] holds the lock of `polled`.
131fn merge_sent_positions(
132    polled: &mut ConsumedPositions,
133    sent: impl IntoIterator<Item = ((String, i32), ConsumedPosition)>,
134) {
135    for (partition, position) in sent {
136        let known = polled.entry(partition).or_insert(position);
137        if known.ownership_id != position.ownership_id || known.offset < position.offset {
138            *known = position;
139        }
140    }
141}
142
143/// Check the offsets of [`Consumer::commit_offsets_sync`], and pair each
144/// with the ownership id of its partition in `assigned`.
145///
146/// Kafka's `OffsetAndMetadata` rejects a negative offset. Kafka accepts any
147/// other offset, also one past the consumed position.
148fn selected_offsets_with_ownership(
149    offsets: HashMap<(String, i32), OffsetAndMetadata>,
150    assigned: &HashMap<(String, i32), u64>,
151) -> Result<PendingOffsets, ConsumerError> {
152    let mut pending = HashMap::with_capacity(offsets.len());
153    for (partition, offset) in offsets {
154        if offset.offset < 0 {
155            return Err(ConsumerError::InvalidOffset(offset.offset));
156        }
157        let Some(&ownership_id) = assigned.get(&partition) else {
158            let (topic, partition) = partition;
159            return Err(ConsumerError::IllegalState(format!(
160                "cannot commit unassigned partition {topic}-{partition}"
161            )));
162        };
163        pending.insert(partition, (offset, ownership_id));
164    }
165    Ok(pending)
166}
167
168/// Take the offsets of an asynchronous commit.
169///
170/// With `auto_commit`, the function also raises the positions for the commit
171/// before a `JoinGroup` to these offsets. See [`AutoCommit::record_sent`].
172async fn snapshot_commit_offsets(
173    commit_identity: &Arc<Mutex<CommitIdentity>>,
174    offsets: &Arc<Mutex<HashMap<(String, i32), i64>>>,
175    positions: &Arc<Mutex<HashMap<(String, i32), PartitionPosition>>>,
176    auto_commit: Option<&AutoCommit>,
177) -> (HashMap<(String, i32), OffsetAndMetadata>, (i32, String)) {
178    let identity = commit_identity.lock().await.clone();
179    let mut raw_offsets = offsets.lock().await.clone();
180    raw_offsets.retain(|partition, offset| {
181        identity.ownership_ids.contains_key(partition) && has_valid_position(*offset)
182    });
183    if raw_offsets.is_empty() {
184        return (HashMap::new(), (identity.generation, identity.member_id));
185    }
186    let pos = positions.lock().await;
187    let offsets = commit_offsets(raw_offsets, &pos);
188    drop(pos);
189    if let Some(auto_commit) = auto_commit {
190        auto_commit
191            .record_sent(sent_positions(&offsets, &identity.ownership_ids))
192            .await;
193    }
194    (
195        position_commits(offsets),
196        (identity.generation, identity.member_id),
197    )
198}
199
200/// The callback of an asynchronous commit. Kafka's `OffsetCommitCallback`.
201///
202/// The consumer calls it once with the offsets that the commit sent and the
203/// result. An empty map means that the consumer had no position to commit.
204pub type OffsetCommitCallback =
205    Box<dyn FnOnce(&HashMap<(String, i32), OffsetAndMetadata>, Result<(), &ConsumerError>) + Send>;
206
207/// The callbacks of the asynchronous commits that wait for the next snapshot.
208pub type OffsetCommitCallbacks = Arc<StdMutex<Vec<OffsetCommitCallback>>>;
209
210/// Take the callbacks that the next asynchronous commit snapshot covers.
211fn take_callbacks(callbacks: &OffsetCommitCallbacks) -> Vec<OffsetCommitCallback> {
212    mem::take(&mut *callbacks.lock().unwrap_or_else(PoisonError::into_inner))
213}
214
215/// The result that an asynchronous commit gives to its callbacks.
216///
217/// This follows Kafka's `ConsumerCoordinator.OffsetCommitResponseHandler` and
218/// `doCommitOffsetsAsync`. A retriable error becomes
219/// [`ConsumerError::RetriableCommitFailed`]. When partitions have errors of
220/// more than one class, the class with the largest [`PartitionCommitError`]
221/// wins, as in [`auto_commit_outcome`].
222fn async_commit_result(
223    result: Result<OffsetCommitResponse, ConsumerError>,
224    group_id: &str,
225    group_instance_id: Option<&str>,
226) -> Result<(), ConsumerError> {
227    let response = match result {
228        Ok(response) => response,
229        Err(ConsumerError::Client(error))
230            if is_retriable_transport_error(&error) || matches!(error, ClientError::Timeout(_)) =>
231        {
232            return Err(ConsumerError::RetriableCommitFailed(Box::new(
233                ConsumerError::Client(error),
234            )));
235        }
236        // `CommitRoute::send` finds the coordinator again after 15 or 16.
237        // Kafka gives a failed lookup to the callback as
238        // `RetriableCommitFailedException` (`commitOffsetsAsync`).
239        Err(ConsumerError::CoordinatorUnavailable) => {
240            return Err(ConsumerError::RetriableCommitFailed(Box::new(
241                ConsumerError::CoordinatorUnavailable,
242            )));
243        }
244        Err(ConsumerError::Server(code)) if is_retriable_coordinator_code(code) => {
245            return Err(ConsumerError::RetriableCommitFailed(Box::new(
246                ConsumerError::Server(code),
247            )));
248        }
249        Err(error) => return Err(error),
250    };
251    let partitions = || {
252        response.topics.iter().flat_map(|topic| {
253            topic
254                .partitions
255                .iter()
256                .map(move |partition| (topic, partition.error_code))
257        })
258    };
259    let class = PartitionCommitError::of_response(&response);
260    let code = partitions()
261        .map(|(_, code)| code)
262        .find(|code| PartitionCommitError::of(*code) == class)
263        .unwrap_or_default();
264    match class {
265        PartitionCommitError::None => Ok(()),
266        PartitionCommitError::TopicAuthorization => Err(ConsumerError::TopicAuthorizationFailed(
267            partitions()
268                .filter(|(_, code)| *code == TOPIC_AUTHORIZATION_FAILED)
269                .map(|(topic, _)| topic.name.clone())
270                .collect(),
271        )),
272        PartitionCommitError::Retriable | PartitionCommitError::CoordinatorUnknown => Err(
273            ConsumerError::RetriableCommitFailed(Box::new(ConsumerError::Server(code))),
274        ),
275        PartitionCommitError::Rebalance => Err(ConsumerError::CommitFailed),
276        PartitionCommitError::FencedInstanceId => Err(ConsumerError::FencedInstanceId(
277            group_instance_id.unwrap_or_default().to_owned(),
278        )),
279        PartitionCommitError::GroupAuthorization => {
280            Err(ConsumerError::GroupAuthorizationFailed(group_id.to_owned()))
281        }
282        PartitionCommitError::Fatal => Err(ConsumerError::Server(code)),
283    }
284}
285
286/// The error of a synchronous commit that did not succeed within `timeout`.
287///
288/// The message is that of the `TimeoutException` that Kafka's
289/// `ClassicKafkaConsumer.commitSync` throws. `cause` is the last retriable
290/// error, as Kafka's `CommitRequestManager.maybeWrapAsTimeoutException` wraps
291/// it, or `None` when the time ran out during an attempt or a wait.
292fn commit_timeout(timeout: Duration, cause: Option<ConsumerError>) -> ConsumerError {
293    ConsumerError::Timeout {
294        message: format!(
295            "Timeout of {}ms expired before successfully committing offsets",
296            timeout.as_millis()
297        ),
298        cause: cause.map(Box::new),
299    }
300}
301
302#[derive(Clone, Copy)]
303struct CommitDeadline {
304    expires_at: Instant,
305    timeout: Duration,
306}
307
308impl CommitDeadline {
309    fn after(timeout: Duration) -> Self {
310        Self {
311            expires_at: Instant::now() + timeout,
312            timeout,
313        }
314    }
315
316    async fn run<F>(self, commit: F) -> Result<(), ConsumerError>
317    where
318        F: Future<Output = Result<(), ConsumerError>>,
319    {
320        timeout_at(self.expires_at, commit)
321            .await
322            .unwrap_or_else(|_elapsed| Err(commit_timeout(self.timeout, None)))
323    }
324}
325
326/// Build the `OffsetCommit` request of a commit. The request names each
327/// topic, so the version is v9 or lower. See [`TopicNameOffsetCommit`].
328pub fn build_commit_request(
329    group_id: String,
330    generation_id_or_member_epoch: i32,
331    member_id: String,
332    group_instance_id: Option<String>,
333    topics: Vec<OffsetCommitRequestTopic>,
334) -> TopicNameOffsetCommit {
335    TopicNameOffsetCommit::new(OffsetCommitRequest {
336        group_id,
337        generation_id_or_member_epoch,
338        member_id,
339        group_instance_id,
340        topics,
341        ..Default::default()
342    })
343}
344
345#[derive(Clone, Debug, Eq, PartialEq)]
346enum CommitOutcome {
347    Acked(HashSet<(String, i32)>),
348    /// Kafka's `OffsetCommitResponseHandler` raises a retriable error, and
349    /// `commitOffsetsSync` sends the commit again until the timeout.
350    Retriable {
351        code: i16,
352        acknowledged: HashSet<(String, i32)>,
353        /// Kafka calls `markCoordinatorUnknown` before the retry, so the next
354        /// attempt finds the coordinator again.
355        find_coordinator: bool,
356    },
357}
358
359/// What a rebalance-class partition error (`ILLEGAL_GENERATION`,
360/// `UNKNOWN_MEMBER_ID`, `REBALANCE_IN_PROGRESS` and a `FENCED_INSTANCE_ID`
361/// that followed a generation change) raises, as Kafka's
362/// `ConsumerCoordinator.OffsetCommitResponseHandler.handle` does.
363#[derive(Clone, Copy, Debug, Eq, PartialEq)]
364enum RebalanceCommitFailure {
365    /// The generation is still the request's: no rebalance completed locally
366    /// since this commit was sent, so the broker's rejection means this
367    /// member has genuinely been removed from the group. Kafka's
368    /// `CommitFailedException`.
369    CommitFailed,
370    /// `REBALANCE_IN_PROGRESS`, or a generation-changing code seen after the
371    /// generation changed: a rebalance already completed locally while the
372    /// request was outstanding, and the error is a stale artifact of that
373    /// ordinary, already-resolved rebalance. Kafka's
374    /// `RebalanceInProgressException`.
375    RebalanceInProgress,
376}
377
378/// The consumer state that decides what an `OffsetCommit` partition error
379/// means for a synchronous commit.
380#[derive(Clone, Copy, Debug)]
381struct CommitResponseContext<'a> {
382    group_id: &'a str,
383    group_instance_id: Option<&'a str>,
384    /// The coordinator task runs, so it can join the group again.
385    coordinator_alive: bool,
386    /// The generation and the member id are still the ones of the request.
387    /// Kafka's `CoordinatorResponseHandler.generationUnchanged`.
388    generation_unchanged: bool,
389}
390
391/// Map an `OffsetCommit` response of a synchronous commit to a result, as
392/// Kafka's `ConsumerCoordinator.OffsetCommitResponseHandler.handle` and
393/// `commitOffsetsSync` do.
394///
395/// - `0`: the partition is acknowledged.
396/// - `GROUP_AUTHORIZATION_FAILED (30)`: [`ConsumerError::GroupAuthorizationFailed`].
397/// - `TOPIC_AUTHORIZATION_FAILED (29)`: the function collects the topics and
398///   returns [`ConsumerError::TopicAuthorizationFailed`] when no other partition
399///   has an error.
400/// - `UNKNOWN_TOPIC_OR_PARTITION (3)`, `COORDINATOR_LOAD_IN_PROGRESS (14)`:
401///   retriable.
402/// - `REQUEST_TIMED_OUT (7)`, `COORDINATOR_NOT_AVAILABLE (15)`,
403///   `NOT_COORDINATOR (16)`: retriable after the consumer finds the coordinator
404///   again.
405/// - `FENCED_INSTANCE_ID (82)` with the generation of the request:
406///   [`ConsumerError::FencedInstanceId`].
407/// - `ILLEGAL_GENERATION (22)`, `UNKNOWN_MEMBER_ID (25)` and `82` seen while
408///   the generation is still the request's (no rebalance completed locally
409///   since this commit was sent, so the broker's rejection means this member
410///   was genuinely removed from the group): [`ConsumerError::CommitFailed`].
411/// - `REBALANCE_IN_PROGRESS (27)`, and `22`/`25`/`82` seen after the
412///   generation changed (a rebalance already completed locally while the
413///   request was outstanding, so the error is a stale artifact of it):
414///   [`ConsumerError::RebalanceInProgress`].
415/// - Any other code, for example `OFFSET_METADATA_TOO_LARGE (12)` and
416///   `INVALID_COMMIT_OFFSET_SIZE (28)`: [`ConsumerError::Server`].
417///
418/// This follows Kafka's classic consumer exactly: `commitOffsetsSync` does not
419/// retry a rebalance-class error, and it commits nothing for the request that
420/// hit it. The krabka coordinator task still joins the group again in the
421/// background regardless of this outcome, so a caller that calls `poll()`
422/// after the error and commits again finds a consumer that is at, or well on
423/// its way to, the next generation.
424///
425/// Kafka stops at the first partition error other than `29`, so its result
426/// for a response with errors of more than one class depends on the partition
427/// order. This function gives a final error precedence over a rebalance-class
428/// error, a rebalance-class error over a retriable error, and a retriable
429/// error over `29`.
430fn commit_response_outcome(
431    resp: &OffsetCommitResponse,
432    context: CommitResponseContext<'_>,
433) -> Result<CommitOutcome, ConsumerError> {
434    let mut rebalance = None;
435    let mut retriable = None;
436    let mut find_coordinator = false;
437    let mut unauthorized_topics = BTreeSet::new();
438    let mut acknowledged = HashSet::new();
439    for topic in &resp.topics {
440        for partition in &topic.partitions {
441            let code = partition.error_code;
442            match PartitionCommitError::of(code) {
443                PartitionCommitError::None => {
444                    acknowledged.insert((topic.name.clone(), partition.partition_index));
445                }
446                PartitionCommitError::TopicAuthorization => {
447                    unauthorized_topics.insert(topic.name.clone());
448                }
449                PartitionCommitError::Retriable => {
450                    retriable.get_or_insert(code);
451                }
452                PartitionCommitError::CoordinatorUnknown => {
453                    retriable.get_or_insert(code);
454                    find_coordinator = true;
455                }
456                PartitionCommitError::FencedInstanceId if context.generation_unchanged => {
457                    return Err(ConsumerError::FencedInstanceId(
458                        context.group_instance_id.unwrap_or_default().to_owned(),
459                    ));
460                }
461                PartitionCommitError::Rebalance | PartitionCommitError::FencedInstanceId => {
462                    // `REBALANCE_IN_PROGRESS` always means a rebalance is
463                    // under way. The other codes mean that only once the
464                    // generation has already moved past the request's; while
465                    // it is still the request's, the broker's rejection is
466                    // real: this identity is no longer an active member.
467                    let failure = if context.coordinator_alive
468                        && (code == REBALANCE_IN_PROGRESS || !context.generation_unchanged)
469                    {
470                        RebalanceCommitFailure::RebalanceInProgress
471                    } else {
472                        RebalanceCommitFailure::CommitFailed
473                    };
474                    rebalance.get_or_insert(failure);
475                }
476                PartitionCommitError::GroupAuthorization => {
477                    return Err(ConsumerError::GroupAuthorizationFailed(
478                        context.group_id.to_owned(),
479                    ));
480                }
481                PartitionCommitError::Fatal => return Err(ConsumerError::Server(code)),
482            }
483        }
484    }
485    match (rebalance, retriable) {
486        (Some(RebalanceCommitFailure::CommitFailed), _) => Err(ConsumerError::CommitFailed),
487        (Some(RebalanceCommitFailure::RebalanceInProgress), _) => Err(
488            ConsumerError::RebalanceInProgress(context.group_id.to_owned()),
489        ),
490        (None, Some(code)) => Ok(CommitOutcome::Retriable {
491            code,
492            acknowledged,
493            find_coordinator,
494        }),
495        (None, None) if !unauthorized_topics.is_empty() => {
496            Err(ConsumerError::TopicAuthorizationFailed(unauthorized_topics))
497        }
498        (None, None) => Ok(CommitOutcome::Acked(acknowledged)),
499    }
500}
501
502fn retain_continuously_owned(
503    pending: &mut HashMap<(String, i32), (OffsetAndMetadata, u64)>,
504    current: &HashMap<(String, i32), u64>,
505) {
506    pending.retain(|partition, (_, ownership_id)| current.get(partition) == Some(ownership_id));
507}
508
509/// The committable position of one owned partition.
510#[derive(Clone, Copy, Debug, Eq, PartialEq)]
511pub struct ConsumedPosition {
512    pub offset: i64,
513    pub leader_epoch: i32,
514    /// The ownership that the position belongs to. A commit sends the position
515    /// only while the consumer still holds this ownership.
516    pub ownership_id: u64,
517}
518
519/// Committable positions, keyed like `Consumer::next_offsets`.
520pub type ConsumedPositions = HashMap<(String, i32), ConsumedPosition>;
521
522/// The offsets of a commit, each with the ownership id of its partition.
523pub type PendingOffsets = HashMap<(String, i32), (OffsetAndMetadata, u64)>;
524
525/// Whether `next_offset` is a fetch position that Kafka can commit.
526///
527/// A reset sentinel is a reset that `poll` has not resolved yet. Kafka's
528/// `SubscriptionState.allConsumed` skips a partition without a valid position.
529fn has_valid_position(next_offset: i64) -> bool {
530    next_offset >= 0 && !is_reset_sentinel(next_offset)
531}
532
533/// Kafka's `SubscriptionState.allConsumed`: the position and leader epoch of
534/// each owned partition that has a valid position.
535pub fn all_consumed(
536    ownership_ids: &HashMap<(String, i32), u64>,
537    next_offsets: &HashMap<(String, i32), i64>,
538    positions: &HashMap<(String, i32), PartitionPosition>,
539) -> ConsumedPositions {
540    next_offsets
541        .iter()
542        .filter(|(_, offset)| has_valid_position(**offset))
543        .filter_map(|(partition, offset)| {
544            ownership_ids.get(partition).map(|ownership_id| {
545                let leader_epoch = positions
546                    .get(partition)
547                    .map_or(-1, |position| position.offset_epoch.get());
548                (
549                    partition.clone(),
550                    ConsumedPosition {
551                        offset: *offset,
552                        leader_epoch,
553                        ownership_id: *ownership_id,
554                    },
555                )
556            })
557        })
558        .collect()
559}
560
561/// The `(offset, leader_epoch)` of each position whose ownership is still
562/// current.
563fn owned_offsets(
564    consumed: &ConsumedPositions,
565    ownership_ids: &HashMap<(String, i32), u64>,
566) -> HashMap<(String, i32), (i64, i32)> {
567    consumed
568        .iter()
569        .filter(|(partition, position)| {
570            ownership_ids.get(*partition) == Some(&position.ownership_id)
571        })
572        .map(|(partition, position)| (partition.clone(), (position.offset, position.leader_epoch)))
573        .collect()
574}
575
576/// The committable positions of the `(offset, leader_epoch)` pairs that a
577/// commit sends, with the ownership of each partition.
578fn sent_positions(
579    offsets: &HashMap<(String, i32), (i64, i32)>,
580    ownership_ids: &HashMap<(String, i32), u64>,
581) -> Vec<((String, i32), ConsumedPosition)> {
582    offsets
583        .iter()
584        .filter_map(|(partition, (offset, leader_epoch))| {
585            ownership_ids.get(partition).map(|ownership_id| {
586                (
587                    partition.clone(),
588                    ConsumedPosition {
589                        offset: *offset,
590                        leader_epoch: *leader_epoch,
591                        ownership_id: *ownership_id,
592                    },
593                )
594            })
595        })
596        .collect()
597}
598
599/// The committable positions of the offsets that a synchronous commit sends.
600fn pending_positions(
601    pending: &HashMap<(String, i32), (OffsetAndMetadata, u64)>,
602) -> Vec<((String, i32), ConsumedPosition)> {
603    pending
604        .iter()
605        .map(|(partition, (offset, ownership_id))| {
606            (
607                partition.clone(),
608                ConsumedPosition {
609                    offset: offset.offset,
610                    leader_epoch: offset.leader_epoch.unwrap_or(-1),
611                    ownership_id: *ownership_id,
612                },
613            )
614        })
615        .collect()
616}
617
618/// When a synchronous commit raises the positions for the commit before a
619/// `JoinGroup`. See [`AutoCommit::record_sent`].
620#[derive(Clone, Copy, Debug, Eq, PartialEq)]
621enum RecordSent {
622    /// A commit of the fetch positions records them before it sends, so a
623    /// commit before a `JoinGroup` cannot move the committed offset back
624    /// behind a request whose result is not known.
625    BeforeSend,
626    /// A commit of caller-selected offsets records only what the coordinator
627    /// acknowledged. Such an offset can be past the fetch position, and a
628    /// rejected one must not reach the commit before a `JoinGroup`. Kafka's
629    /// `onJoinPrepare` commits `allConsumed`, the fetch positions.
630    AfterAck,
631}
632
633/// How an automatic commit ended.
634#[derive(Clone, Copy, Debug, Eq, PartialEq)]
635pub enum AutoCommitOutcome {
636    Committed,
637    /// Kafka's `RetriableCommitFailedException`: the coordinator is loading,
638    /// moved or timed out, the topic is unknown, or the connection failed.
639    Retriable,
640    Failed,
641}
642
643/// What Kafka's `ConsumerCoordinator.OffsetCommitResponseHandler.handle` does
644/// with the error code of one partition. A larger class takes precedence in
645/// [`auto_commit_outcome`] when a response has errors of more than one class.
646#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
647enum PartitionCommitError {
648    None,
649    /// `TOPIC_AUTHORIZATION_FAILED`. Kafka collects the unauthorized topics
650    /// and raises `TopicAuthorizationException` only when no other partition
651    /// has an error. Any other error takes precedence.
652    TopicAuthorization,
653    /// `UNKNOWN_TOPIC_OR_PARTITION` and `COORDINATOR_LOAD_IN_PROGRESS`. Kafka
654    /// retries.
655    Retriable,
656    /// `REQUEST_TIMED_OUT`, `COORDINATOR_NOT_AVAILABLE` and `NOT_COORDINATOR`.
657    /// Kafka calls `markCoordinatorUnknown` and retries.
658    CoordinatorUnknown,
659    /// `ILLEGAL_GENERATION`, `UNKNOWN_MEMBER_ID` and `REBALANCE_IN_PROGRESS`.
660    /// Kafka raises `CommitFailedException` or `RebalanceInProgressException`.
661    Rebalance,
662    /// `FENCED_INSTANCE_ID`. Kafka raises `FencedInstanceIdException` while the
663    /// generation is unchanged, and a rebalance error after it changed.
664    FencedInstanceId,
665    /// `GROUP_AUTHORIZATION_FAILED`. Kafka raises
666    /// `GroupAuthorizationException`.
667    GroupAuthorization,
668    /// Any other code. Kafka raises the error to the application:
669    /// `OFFSET_METADATA_TOO_LARGE` and `INVALID_COMMIT_OFFSET_SIZE` as
670    /// themselves, other codes as "Unexpected error in commit".
671    Fatal,
672}
673
674impl PartitionCommitError {
675    fn of(code: i16) -> Self {
676        match code {
677            0 => Self::None,
678            TOPIC_AUTHORIZATION_FAILED => Self::TopicAuthorization,
679            UNKNOWN_TOPIC_OR_PARTITION | COORDINATOR_LOAD_IN_PROGRESS => Self::Retriable,
680            REQUEST_TIMED_OUT | COORDINATOR_NOT_AVAILABLE | NOT_COORDINATOR => {
681                Self::CoordinatorUnknown
682            }
683            ILLEGAL_GENERATION | UNKNOWN_MEMBER_ID | REBALANCE_IN_PROGRESS => Self::Rebalance,
684            FENCED_INSTANCE_ID => Self::FencedInstanceId,
685            GROUP_AUTHORIZATION_FAILED => Self::GroupAuthorization,
686            _ => Self::Fatal,
687        }
688    }
689}
690
691impl PartitionCommitError {
692    /// The class of `response` in [`auto_commit_outcome`]: the largest class of
693    /// its partitions.
694    fn of_response(response: &OffsetCommitResponse) -> Self {
695        response
696            .topics
697            .iter()
698            .flat_map(|topic| topic.partitions.iter())
699            .map(|partition| Self::of(partition.error_code))
700            .max()
701            .unwrap_or(Self::None)
702    }
703}
704
705/// Whether `response` makes Kafka's `OffsetCommitResponseHandler` call
706/// `markCoordinatorUnknown`, so the consumer finds the coordinator again and
707/// the commit is retriable. A partition with a final error or a rebalance
708/// error takes precedence, as in [`auto_commit_outcome`].
709pub fn names_moved_coordinator(response: &OffsetCommitResponse) -> bool {
710    PartitionCommitError::of_response(response) == PartitionCommitError::CoordinatorUnknown
711}
712
713/// Classify the result of one automatic `OffsetCommit`, as Kafka's
714/// `ConsumerCoordinator.OffsetCommitResponseHandler` does.
715///
716/// The function reads the error of every partition. Kafka's handler stops at
717/// the first partition error other than `TOPIC_AUTHORIZATION_FAILED`, so its
718/// result for a response with a retriable and a fatal partition error depends
719/// on the partition order. That order comes from hash maps, here and in Kafka
720/// (`ConsumerCoordinator.sendOffsetCommitRequest`). This function gives the
721/// fatal error precedence, so the result does not depend on that order. The
722/// commit before a `JoinGroup` then does not retry until the rebalance timeout
723/// when a partition can never commit.
724pub fn auto_commit_outcome(
725    result: &Result<OffsetCommitResponse, ConsumerError>,
726) -> AutoCommitOutcome {
727    match result {
728        Ok(response) => match PartitionCommitError::of_response(response) {
729            PartitionCommitError::None => AutoCommitOutcome::Committed,
730            PartitionCommitError::Retriable | PartitionCommitError::CoordinatorUnknown => {
731                AutoCommitOutcome::Retriable
732            }
733            PartitionCommitError::TopicAuthorization
734            | PartitionCommitError::Rebalance
735            | PartitionCommitError::FencedInstanceId
736            | PartitionCommitError::GroupAuthorization
737            | PartitionCommitError::Fatal => AutoCommitOutcome::Failed,
738        },
739        Err(ConsumerError::Client(error))
740            if is_retriable_transport_error(error) || matches!(error, ClientError::Timeout(_)) =>
741        {
742            AutoCommitOutcome::Retriable
743        }
744        Err(ConsumerError::Server(code)) if is_retriable_coordinator_code(*code) => {
745            AutoCommitOutcome::Retriable
746        }
747        Err(_) => AutoCommitOutcome::Failed,
748    }
749}
750
751/// Kafka's `enable.auto.commit` state for one consumer. The consumer and its
752/// coordinator task share it.
753#[derive(Clone, Debug)]
754pub struct AutoCommit {
755    /// Kafka's `auto.commit.interval.ms`.
756    interval: Duration,
757    /// When the next interval commit from `poll` is due. Kafka's
758    /// `ConsumerCoordinator.nextAutoCommitTimer`.
759    next_due: Arc<Mutex<Instant>>,
760    /// The positions at the start of the latest `poll`, raised to each offset
761    /// that a commit sent after that `poll` for the same ownership.
762    ///
763    /// Kafka runs `onJoinPrepare` inside `poll`, so its pre-rebalance commit
764    /// includes only records that the application received before that `poll`.
765    /// The krabka coordinator task rebalances between two polls, while the
766    /// application can still process the records of the last `poll`. The task
767    /// therefore commits these positions and not the live ones.
768    ///
769    /// A commit of the application can send a newer offset than the positions
770    /// of the latest `poll`. The pre-rebalance commit must not move the
771    /// committed offset back behind it. Kafka's `allConsumed` is never behind a
772    /// `commitSync()` of the same ownership, because both read the positions.
773    polled: Arc<Mutex<ConsumedPositions>>,
774}
775
776impl AutoCommit {
777    pub(crate) fn new(interval: Duration) -> Self {
778        Self {
779            interval,
780            next_due: Arc::new(Mutex::new(Instant::now() + interval)),
781            polled: Arc::new(Mutex::new(HashMap::new())),
782        }
783    }
784
785    /// Move the positions for the commit before a `JoinGroup` after `poll`
786    /// resets fetch positions.
787    ///
788    /// Kafka's `allConsumed` reads the current position, so a reset in `poll`
789    /// changes what `onJoinPrepare` commits. `Some(offset)` is a truncation or
790    /// an out-of-range reset: the commit sends that offset. The position of a
791    /// reset has no known leader epoch here, so the commit sends none (`-1`).
792    /// `None` is a reset that waits for `ListOffsets`: the partition has no
793    /// valid position, and the commit skips it, as `allConsumed` does. A
794    /// partition without a position of the latest `poll` stays without one.
795    pub(crate) async fn reset_polled(
796        &self,
797        resets: impl IntoIterator<Item = ((String, i32), Option<i64>)>,
798    ) {
799        let mut polled = self.polled.lock().await;
800        for (partition, offset) in resets {
801            match offset {
802                Some(offset) => {
803                    if let Some(position) = polled.get_mut(&partition) {
804                        position.offset = offset;
805                        position.leader_epoch = -1;
806                    }
807                }
808                None => {
809                    polled.remove(&partition);
810                }
811            }
812        }
813    }
814
815    /// Raise the positions for the commit before a `JoinGroup` to the offsets
816    /// that a commit sends. Call it while you hold `commit_serialization`,
817    /// before the commit sends its request.
818    pub(crate) async fn record_sent(
819        &self,
820        sent: impl IntoIterator<Item = ((String, i32), ConsumedPosition)>,
821    ) {
822        merge_sent_positions(&mut *self.polled.lock().await, sent);
823    }
824
825    /// Start the interval again. Kafka's `onJoinComplete` resets
826    /// `nextAutoCommitTimer` after each assignment.
827    pub(crate) async fn restart_interval(&self) {
828        *self.next_due.lock().await = Instant::now() + self.interval;
829    }
830
831    /// The `(offset, leader_epoch)` of each position of the latest `poll`
832    /// whose ownership is still current.
833    pub(crate) async fn polled_offsets(
834        &self,
835        ownership_ids: &HashMap<(String, i32), u64>,
836    ) -> HashMap<(String, i32), (i64, i32)> {
837        owned_offsets(&*self.polled.lock().await, ownership_ids)
838    }
839}
840
841/// The route of a background commit to the group coordinator.
842#[derive(Clone)]
843struct CommitRoute {
844    client: krabka_client_core::Client,
845    group_id: String,
846    group_instance_id: Option<String>,
847    coordinator_id: Arc<AtomicI32>,
848    retry_policy: coordinator::CoordinatorRetryPolicy,
849}
850
851impl CommitRoute {
852    /// Send one `OffsetCommit` to the coordinator.
853    ///
854    /// If the coordinator moved or the connection failed, find the coordinator
855    /// again and send once more. A background commit does not wait through the
856    /// full retry loop.
857    async fn send(
858        &self,
859        topics: Vec<OffsetCommitRequestTopic>,
860        generation: i32,
861        member_id: &str,
862    ) -> Result<OffsetCommitResponse, ConsumerError> {
863        let request = |topics| {
864            build_commit_request(
865                self.group_id.clone(),
866                generation,
867                member_id.to_owned(),
868                self.group_instance_id.clone(),
869                topics,
870            )
871        };
872        let target = self.coordinator_id.load(Ordering::Relaxed);
873        let result = self
874            .client
875            .broker(target)
876            .send(request(topics.clone()))
877            .await;
878        let moved = match &result {
879            Ok(response) => names_moved_coordinator(response),
880            Err(error) => is_retriable_transport_error(error),
881        };
882        if !moved {
883            return result.map_err(ConsumerError::from);
884        }
885        let id = find_coordinator(&self.client, &self.group_id, self.retry_policy).await?;
886        self.coordinator_id.store(id, Ordering::Relaxed);
887        self.client
888            .broker(id)
889            .send(request(topics))
890            .await
891            .map_err(ConsumerError::from)
892    }
893}
894
895#[derive(Debug)]
896#[allow(dead_code)] // The tracing spans read these fields through `Debug`.
897struct CommitTraceContext<'a> {
898    group_id: &'a str,
899    member_id: String,
900    generation: i32,
901}
902
903fn commit_trace_context(consumer: &Consumer) -> CommitTraceContext<'_> {
904    CommitTraceContext {
905        group_id: &consumer.group_id,
906        member_id: consumer.member_id(),
907        generation: consumer.current_generation.load(Ordering::Relaxed),
908    }
909}
910
911impl Consumer {
912    fn commit_route(&self) -> CommitRoute {
913        CommitRoute {
914            client: self.client.clone(),
915            group_id: self.group_id.clone(),
916            group_instance_id: self.group_instance_id.clone(),
917            coordinator_id: Arc::clone(&self.coordinator_id),
918            retry_policy: self.retry_policy,
919        }
920    }
921
922    /// The identity and the committable positions of this consumer now.
923    async fn consumed_positions(&self) -> (CommitIdentity, ConsumedPositions) {
924        let identity = self.commit_identity.lock().await.clone();
925        let next_offsets = self.next_offsets.lock().await.clone();
926        let positions = self.positions.lock().await.clone();
927        let consumed = all_consumed(&identity.ownership_ids, &next_offsets, &positions);
928        (identity, consumed)
929    }
930
931    /// The auto commit of `poll`: Kafka's
932    /// `ConsumerCoordinator.maybeAutoCommitOffsetsAsync(now)`.
933    ///
934    /// This method records the positions for a later pre-rebalance commit. When
935    /// the interval has passed, it sends an asynchronous commit of the
936    /// positions. A failure goes to the log. A retriable failure makes the next
937    /// commit due after the retry backoff, as Kafka's `autoCommitOffsetsAsync`
938    /// does.
939    pub(crate) async fn maybe_auto_commit_async(&self) {
940        let Some(auto_commit) = &self.auto_commit else {
941            return;
942        };
943        let (identity, consumed) = self.consumed_positions().await;
944        auto_commit.polled.lock().await.clone_from(&consumed);
945        let mut next_due = auto_commit.next_due.lock().await;
946        let now = Instant::now();
947        if now < *next_due {
948            return;
949        }
950        // Take the commit lock before `poll` continues, so a later
951        // `commit_sync` cannot commit newer offsets before this older commit.
952        // If another commit holds the lock, `poll` does not wait for it: the
953        // commit stays due and the next `poll` tries again.
954        let Ok(commit_guard) = Arc::clone(&self.commit_serialization).try_lock_owned() else {
955            return;
956        };
957        *next_due = now + auto_commit.interval;
958        drop(next_due);
959        let offsets = owned_offsets(&consumed, &identity.ownership_ids);
960        if offsets.is_empty() {
961            return;
962        }
963        let route = self.commit_route();
964        let next_due = Arc::clone(&auto_commit.next_due);
965        let retry_backoff = self.retry_policy.initial_backoff;
966        tokio::spawn(async move {
967            let _commit_guard = commit_guard;
968            let result = route
969                .send(
970                    build_commit_topics(position_commits(offsets)),
971                    identity.generation,
972                    &identity.member_id,
973                )
974                .await;
975            match auto_commit_outcome(&result) {
976                AutoCommitOutcome::Committed => {}
977                AutoCommitOutcome::Retriable => {
978                    tracing::debug!(
979                        group = %route.group_id,
980                        ?result,
981                        "asynchronous auto commit failed with a retriable error"
982                    );
983                    *next_due.lock().await = Instant::now() + retry_backoff;
984                }
985                AutoCommitOutcome::Failed => {
986                    tracing::warn!(
987                        group = %route.group_id,
988                        ?result,
989                        "asynchronous auto commit failed"
990                    );
991                }
992            }
993        });
994    }
995
996    /// The auto commit of `close`: Kafka's
997    /// `ConsumerCoordinator.maybeAutoCommitOffsetsSync(timer)`.
998    ///
999    /// This method commits the current positions and waits for the result, up
1000    /// to Kafka's default close timeout. A failure goes to the log, and `close`
1001    /// continues.
1002    /// Wait until no asynchronous commit is queued or running, or until
1003    /// `deadline`. Kafka's `ConsumerCoordinator.close` polls while
1004    /// `pendingAsyncCommits > 0 && timer.notExpired()` and invokes the
1005    /// completed callbacks.
1006    pub(crate) async fn wait_for_async_commits(&self, deadline: Instant) {
1007        let wait = async {
1008            loop {
1009                if self.commit_async_state.load(Ordering::Acquire) == ASYNC_COMMIT_IDLE {
1010                    return;
1011                }
1012                // The worker holds the lock while it snapshots and sends.
1013                drop(self.commit_serialization.lock().await);
1014                sleep(Duration::from_millis(1)).await;
1015            }
1016        };
1017        if timeout_at(deadline, wait).await.is_err() {
1018            tracing::warn!(
1019                group = %self.group_id,
1020                "asynchronous commits were still pending when the close timeout expired"
1021            );
1022        }
1023    }
1024
1025    pub(crate) async fn auto_commit_on_close(&self, deadline: Instant) {
1026        if self.auto_commit.is_none() {
1027            return;
1028        }
1029        // The close timeout also bounds the wait for another commit that holds
1030        // the commit lock.
1031        let commit = async {
1032            let _commit_guard = self.commit_serialization.lock().await;
1033            let (_, consumed) = self.consumed_positions().await;
1034            let pending = consumed
1035                .into_iter()
1036                .map(|(partition, position)| {
1037                    (
1038                        partition,
1039                        (
1040                            OffsetAndMetadata::of_position(position.offset, position.leader_epoch),
1041                            position.ownership_id,
1042                        ),
1043                    )
1044                })
1045                .collect::<HashMap<_, _>>();
1046            if pending.is_empty() {
1047                return Ok(());
1048            }
1049            let retry_deadline = CommitDeadline::after(self.retry_policy.timeout);
1050            self.commit_pending_offsets(pending, RecordSent::BeforeSend, retry_deadline)
1051                .await
1052        };
1053        match timeout_at(deadline, commit).await {
1054            Ok(Ok(())) => {}
1055            Ok(Err(error)) => {
1056                tracing::warn!(group = %self.group_id, %error, "synchronous auto commit on close failed");
1057            }
1058            Err(_) => {
1059                tracing::warn!(group = %self.group_id, "synchronous auto commit on close timed out");
1060            }
1061        }
1062    }
1063}
1064
1065impl Consumer {
1066    /// Commit the current next-offsets for every assigned partition.
1067    ///
1068    /// This method blocks until the broker acks, for at most
1069    /// `default_api_timeout` (Kafka's `default.api.timeout.ms`).
1070    #[cfg_attr(test, mutants::skip)] // cargo-mutants: I/O-bound coordinator RPC, exercised by integration tests
1071    #[tracing::instrument(
1072        name = "consumer.commit_sync",
1073        level = "debug",
1074        skip_all,
1075        fields(commit_context = ?commit_trace_context(self), partitions = field::Empty),
1076        err(level = "debug")
1077    )]
1078    /// # Errors
1079    /// Returns an error when configuration is invalid, protocol encoding fails, or the broker rejects the commit with an error that is not retriable. A retriable error, such as `NOT_COORDINATOR` or a failed request, is retried. Returns [`ConsumerError::Timeout`] when the call, including any wait for a concurrent commit, the broker round trips and the retries, does not finish within `default_api_timeout`. When a retriable error left no time for another attempt, that error is the timeout's cause, as in Kafka's `commitSync`.
1080    pub async fn commit_sync(&self) -> Result<(), ConsumerError> {
1081        self.require_group_id()?;
1082        let deadline = CommitDeadline::after(self.default_api_timeout.to_std());
1083        // `timeout_at` bounds the whole call, including the wait for
1084        // `commit_serialization` and the broker round trip inside
1085        // `commit_pending_offsets`, not only whether a retry starts.
1086        let commit = async {
1087            let _commit_guard = self.commit_serialization.lock().await;
1088            let pending = {
1089                let identity = self.commit_identity.lock().await;
1090                self.ensure_active_group(&identity)?;
1091                let offsets = self.next_offsets.lock().await;
1092                let positions = self.positions.lock().await;
1093                // Kafka's `commitSync()` commits `SubscriptionState.allConsumed`:
1094                // the position and the leader epoch at the call.
1095                all_consumed(&identity.ownership_ids, &offsets, &positions)
1096                    .into_iter()
1097                    .map(|(partition, position)| {
1098                        (
1099                            partition,
1100                            (
1101                                OffsetAndMetadata::of_position(
1102                                    position.offset,
1103                                    position.leader_epoch,
1104                                ),
1105                                position.ownership_id,
1106                            ),
1107                        )
1108                    })
1109                    .collect::<HashMap<_, _>>()
1110            };
1111            if pending.is_empty() {
1112                return Ok(());
1113            }
1114            tracing::Span::current().record("partitions", pending.len());
1115
1116            self.commit_pending_offsets(pending, RecordSent::BeforeSend, deadline)
1117                .await
1118        };
1119        deadline.run(commit).await
1120    }
1121
1122    /// Commit caller-selected offsets for currently assigned partitions.
1123    ///
1124    /// This is Kafka's `commitSync(Map<TopicPartition, OffsetAndMetadata>)`.
1125    /// The call commits each offset with its leader epoch and metadata. It
1126    /// does not commit other partitions and does not change the fetch
1127    /// positions. An offset can be past the consumed position, as in Kafka.
1128    /// This method blocks until the broker acks, for at most
1129    /// `default_api_timeout` (Kafka's `default.api.timeout.ms`).
1130    ///
1131    /// # Errors
1132    ///
1133    /// `Ok(())` means each requested offset was broker-acknowledged while this
1134    /// consumer continuously owned its partition, or that ownership ended and
1135    /// the new owner will safely replay from the prior committed offset.
1136    ///
1137    /// Returns an error if an offset is negative, targets an unassigned
1138    /// partition, or the coordinator rejects the commit. A rebalance error
1139    /// ([`ConsumerError::CommitFailed`] or
1140    /// [`ConsumerError::RebalanceInProgress`]) is not retried and commits
1141    /// nothing: call `poll()` and commit again, as Kafka's
1142    /// `commitSync` requires. A retriable error, such as `NOT_COORDINATOR` or
1143    /// a failed request, is retried. Returns [`ConsumerError::Timeout`] when
1144    /// the call, including any wait for a concurrent commit, the broker round
1145    /// trips and the retries, does not finish within `default_api_timeout`.
1146    /// When a retriable error left no time for another attempt, that error is
1147    /// the timeout's cause, as in Kafka's `commitSync`.
1148    pub async fn commit_offsets_sync(
1149        &self,
1150        offsets: HashMap<(String, i32), OffsetAndMetadata>,
1151    ) -> Result<(), ConsumerError> {
1152        self.require_group_id()?;
1153        if offsets.is_empty() {
1154            return Ok(());
1155        }
1156        let deadline = CommitDeadline::after(self.default_api_timeout.to_std());
1157        // `timeout_at` bounds the whole call, including the wait for
1158        // `commit_serialization` and the broker round trip inside
1159        // `commit_pending_offsets`, not only whether a retry starts.
1160        let commit = async {
1161            let _commit_guard = self.commit_serialization.lock().await;
1162            let pending = {
1163                let identity = self.commit_identity.lock().await;
1164                self.ensure_active_group(&identity)?;
1165                selected_offsets_with_ownership(offsets, &identity.ownership_ids)?
1166            };
1167
1168            self.commit_pending_offsets(pending, RecordSent::AfterAck, deadline)
1169                .await
1170        };
1171        deadline.run(commit).await
1172    }
1173
1174    /// Fail a synchronous commit when the consumer is not part of an active
1175    /// group.
1176    ///
1177    /// That is the case after a fatal coordinator error removed the member,
1178    /// until the next `poll` joins the group again, and after the coordinator
1179    /// task stopped. Kafka's `ConsumerCoordinator.sendOffsetCommitRequest`
1180    /// raises `CommitFailedException` in this state and sends no request.
1181    ///
1182    /// A fence sets `rejoin_on_poll` in the same critical section in which it
1183    /// clears the ownership. Pass an `identity` that you read under the
1184    /// `commit_identity` lock, so an ownership that the fence cleared always
1185    /// comes with the flag.
1186    fn ensure_active_group(&self, identity: &CommitIdentity) -> Result<(), ConsumerError> {
1187        if identity.rejoin_on_poll
1188            || self.coordinator_shutdown.is_cancelled()
1189            || self
1190                .coordinator_handle
1191                .as_ref()
1192                .is_some_and(JoinHandle::is_finished)
1193        {
1194            return Err(ConsumerError::CommitFailed);
1195        }
1196        Ok(())
1197    }
1198
1199    /// Commit `pending`, and retry a retriable failure until `deadline`.
1200    ///
1201    /// `timeout` is the bound that ends at `deadline`, for the message of the
1202    /// error. A synchronous commit passes the deadline of its own
1203    /// `timeout_at`, so that the retry loop and the bound of the whole call
1204    /// expire at the same instant, however long the call waited before its
1205    /// first attempt.
1206    ///
1207    /// # Errors
1208    ///
1209    /// A non-retriable error returns at once. When a retriable error leaves
1210    /// no time for another attempt, the loop returns
1211    /// [`ConsumerError::Timeout`] with that error as its cause. Kafka's
1212    /// `ConsumerCoordinator.commitOffsetsSync` retries while
1213    /// `timer.notExpired()`, and `ClassicKafkaConsumer.commitSync` then
1214    /// throws `TimeoutException`. The async consumer's
1215    /// `CommitRequestManager.commitSyncWithRetries` completes with
1216    /// `maybeWrapAsTimeoutException` of the last error.
1217    async fn commit_pending_offsets(
1218        &self,
1219        mut pending: PendingOffsets,
1220        record: RecordSent,
1221        deadline: CommitDeadline,
1222    ) -> Result<(), ConsumerError> {
1223        let mut retry_backoff = self.retry_policy.initial_backoff;
1224        loop {
1225            let identity = self.commit_identity.lock().await.clone();
1226            self.ensure_active_group(&identity)?;
1227            retain_continuously_owned(&mut pending, &identity.ownership_ids);
1228            if pending.is_empty() {
1229                return Ok(());
1230            }
1231
1232            if record == RecordSent::BeforeSend
1233                && let Some(auto_commit) = &self.auto_commit
1234            {
1235                auto_commit.record_sent(pending_positions(&pending)).await;
1236            }
1237            let topics = build_commit_topics(
1238                pending
1239                    .iter()
1240                    .map(|(partition, (offset, _))| (partition.clone(), offset.clone()))
1241                    .collect(),
1242            );
1243            let outcome = match self
1244                .commit_topics_once(topics, (identity.generation, identity.member_id.clone()))
1245                .await
1246            {
1247                Ok(outcome) => outcome,
1248                // Kafka's `CoordinatorResponseHandler.onFailure` marks the
1249                // coordinator unknown after a disconnect, and
1250                // `commitOffsetsSync` retries until the timeout. A request
1251                // timeout is a disconnect in Kafka's `NetworkClient`.
1252                Err(ConsumerError::Client(error))
1253                    if is_retriable_transport_error(&error)
1254                        || matches!(error, ClientError::Timeout(_)) =>
1255                {
1256                    if Instant::now() >= deadline.expires_at {
1257                        return Err(commit_timeout(
1258                            deadline.timeout,
1259                            Some(ConsumerError::Client(error)),
1260                        ));
1261                    }
1262                    tracing::warn!(
1263                        group = %self.group_id,
1264                        %error,
1265                        "offset commit request failed; finding the coordinator again and retrying",
1266                    );
1267                    self.client
1268                        .evict_broker(self.coordinator_id.load(Ordering::Relaxed));
1269                    self.find_coordinator_again(deadline.expires_at).await;
1270                    if !self
1271                        .sleep_before_retry(retry_backoff, deadline.expires_at)
1272                        .await
1273                    {
1274                        return Err(commit_timeout(
1275                            deadline.timeout,
1276                            Some(ConsumerError::Client(error)),
1277                        ));
1278                    }
1279                    retry_backoff = next_backoff(retry_backoff, self.retry_policy.max_backoff);
1280                    continue;
1281                }
1282                Err(error) => return Err(error),
1283            };
1284            match outcome {
1285                CommitOutcome::Acked(acknowledged) => {
1286                    if self
1287                        .retain_unacknowledged(record, &mut pending, &acknowledged)
1288                        .await
1289                    {
1290                        return Ok(());
1291                    }
1292                    return Err(ConsumerError::IllegalState(
1293                        "offset commit response omitted requested partitions".into(),
1294                    ));
1295                }
1296                CommitOutcome::Retriable {
1297                    code,
1298                    acknowledged,
1299                    find_coordinator,
1300                } => {
1301                    // Kafka's `ConsumerCoordinator.commitOffsetsSync` sends the
1302                    // commit again while it fails with a retriable error and
1303                    // the timeout has not passed.
1304                    if self
1305                        .retain_unacknowledged(record, &mut pending, &acknowledged)
1306                        .await
1307                    {
1308                        return Ok(());
1309                    }
1310                    if Instant::now() >= deadline.expires_at {
1311                        return Err(commit_timeout(
1312                            deadline.timeout,
1313                            Some(ConsumerError::Server(code)),
1314                        ));
1315                    }
1316                    tracing::warn!(
1317                        group = %self.group_id,
1318                        error_code = code,
1319                        "offset commit failed with a retriable error; retrying",
1320                    );
1321                    if find_coordinator {
1322                        self.find_coordinator_again(deadline.expires_at).await;
1323                    }
1324                    if !self
1325                        .sleep_before_retry(retry_backoff, deadline.expires_at)
1326                        .await
1327                    {
1328                        return Err(commit_timeout(
1329                            deadline.timeout,
1330                            Some(ConsumerError::Server(code)),
1331                        ));
1332                    }
1333                    retry_backoff = next_backoff(retry_backoff, self.retry_policy.max_backoff);
1334                }
1335            }
1336        }
1337    }
1338
1339    /// Raise the positions for the commit before a `JoinGroup` to the
1340    /// `acknowledged` offsets of `pending`, for a [`RecordSent::AfterAck`]
1341    /// commit.
1342    async fn record_acknowledged(
1343        &self,
1344        record: RecordSent,
1345        pending: &HashMap<(String, i32), (OffsetAndMetadata, u64)>,
1346        acknowledged: &HashSet<(String, i32)>,
1347    ) {
1348        if record != RecordSent::AfterAck {
1349            return;
1350        }
1351        let Some(auto_commit) = &self.auto_commit else {
1352            return;
1353        };
1354        let acked = pending
1355            .iter()
1356            .filter(|(partition, _)| acknowledged.contains(*partition))
1357            .map(|(partition, value)| (partition.clone(), value.clone()))
1358            .collect();
1359        auto_commit.record_sent(pending_positions(&acked)).await;
1360    }
1361
1362    async fn retain_unacknowledged(
1363        &self,
1364        record: RecordSent,
1365        pending: &mut HashMap<(String, i32), (OffsetAndMetadata, u64)>,
1366        acknowledged: &HashSet<(String, i32)>,
1367    ) -> bool {
1368        self.record_acknowledged(record, pending, acknowledged)
1369            .await;
1370        pending.retain(|partition, _| !acknowledged.contains(partition));
1371        pending.is_empty()
1372    }
1373
1374    /// Wait `backoff` before the next attempt of a synchronous commit, but not
1375    /// past its retry `deadline`. Return whether time is left for the next
1376    /// attempt. Kafka's `commitOffsetsSync` does `timer.sleep(backoff)` and
1377    /// then sends again only while `timer.notExpired()`.
1378    async fn sleep_before_retry(&self, backoff: Duration, deadline: Instant) -> bool {
1379        sleep_until((Instant::now() + backoff).min(deadline)).await;
1380        Instant::now() < deadline
1381    }
1382
1383    /// Find the group coordinator again, within the time that is left of a
1384    /// synchronous commit with the retry `deadline`. Kafka's
1385    /// `commitOffsetsSync` does this in `coordinatorUnknownAndUnreadySync` after
1386    /// `markCoordinatorUnknown`. If the lookup fails, the next attempt uses the
1387    /// last known coordinator.
1388    async fn find_coordinator_again(&self, deadline: Instant) {
1389        let retry_policy = coordinator::CoordinatorRetryPolicy {
1390            timeout: deadline.saturating_duration_since(Instant::now()),
1391            ..self.retry_policy
1392        };
1393        match find_coordinator(&self.client, &self.group_id, retry_policy).await {
1394            Ok(id) => self.coordinator_id.store(id, Ordering::Relaxed),
1395            Err(error) => {
1396                tracing::warn!(
1397                    group = %self.group_id,
1398                    %error,
1399                    "coordinator lookup for an offset commit failed; retrying with the last known coordinator",
1400                );
1401            }
1402        }
1403    }
1404
1405    /// Send one `OffsetCommit` to the coordinator and map the response.
1406    ///
1407    /// [`Consumer::commit_pending_offsets`] retries a retriable result.
1408    async fn commit_topics_once(
1409        &self,
1410        topics: Vec<OffsetCommitRequestTopic>,
1411        identity: (i32, String),
1412    ) -> Result<CommitOutcome, ConsumerError> {
1413        let (generation, member_id) = identity;
1414        let target = self.coordinator_id.load(Ordering::Relaxed);
1415        let resp = self
1416            .client
1417            .broker(target)
1418            .send(build_commit_request(
1419                self.group_id.clone(),
1420                generation,
1421                member_id.clone(),
1422                self.group_instance_id.clone(),
1423                topics,
1424            ))
1425            .await?;
1426
1427        let current = self.commit_identity.lock().await.clone();
1428        // Without a subscription no coordinator task joins the group again, so
1429        // a rebalance error fails the commit, as in Kafka.
1430        let coordinator_alive = !self.subscription.borrow().is_none()
1431            && self
1432                .coordinator_handle
1433                .as_ref()
1434                .is_none_or(|h| !h.is_finished());
1435        let outcome = commit_response_outcome(
1436            &resp,
1437            CommitResponseContext {
1438                group_id: &self.group_id,
1439                group_instance_id: self.group_instance_id.as_deref(),
1440                coordinator_alive,
1441                generation_unchanged: current.generation == generation
1442                    && current.member_id == member_id,
1443            },
1444        )?;
1445        Ok(outcome)
1446    }
1447
1448    /// Fire-and-forget commit.
1449    ///
1450    /// This method returns after scheduling the latest offsets for a background
1451    /// commit. Calls made while one is queued or running are coalesced into its
1452    /// snapshot or one follow-up snapshot. It does NOT wait for the broker ack.
1453    /// It logs errors and does not return them. Kafka's `commitAsync()` with
1454    /// its `DefaultOffsetCommitCallback`.
1455    #[cfg_attr(test, mutants::skip)] // cargo-mutants: fire-and-forget I/O spawn, exercised by integration tests
1456    #[tracing::instrument(
1457        name = "consumer.commit_async",
1458        level = "debug",
1459        skip_all,
1460        fields(commit_context = ?commit_trace_context(self))
1461    )]
1462    ///
1463    /// # Errors
1464    ///
1465    /// Returns [`ConsumerError::InvalidGroupId`] without a group id.
1466    pub fn commit_async(&self) -> Result<(), ConsumerError> {
1467        self.require_group_id()?;
1468        self.schedule_commit_async();
1469        Ok(())
1470    }
1471
1472    /// Commit the positions in the background, and call `callback` with the
1473    /// result. Kafka's `commitAsync(OffsetCommitCallback)`.
1474    ///
1475    /// The commit is coalesced with other asynchronous commits as
1476    /// [`Consumer::commit_async`] describes. The callback gets the offsets and
1477    /// the result of the commit that covers this call. The consumer calls it
1478    /// on a background task.
1479    ///
1480    /// # Errors
1481    ///
1482    /// Returns [`ConsumerError::InvalidGroupId`] without a group id. The
1483    /// callback then does not run.
1484    pub fn commit_async_with_callback<F>(&self, callback: F) -> Result<(), ConsumerError>
1485    where
1486        F: FnOnce(&HashMap<(String, i32), OffsetAndMetadata>, Result<(), &ConsumerError>)
1487            + Send
1488            + 'static,
1489    {
1490        self.require_group_id()?;
1491        self.commit_async_callbacks
1492            .lock()
1493            .unwrap_or_else(PoisonError::into_inner)
1494            .push(Box::new(callback));
1495        self.schedule_commit_async();
1496        Ok(())
1497    }
1498
1499    fn schedule_commit_async(&self) {
1500        loop {
1501            match self.commit_async_state.load(Ordering::Acquire) {
1502                ASYNC_COMMIT_IDLE => {
1503                    if self
1504                        .commit_async_state
1505                        .compare_exchange(
1506                            ASYNC_COMMIT_IDLE,
1507                            ASYNC_COMMIT_RUNNING,
1508                            Ordering::AcqRel,
1509                            Ordering::Acquire,
1510                        )
1511                        .is_ok()
1512                    {
1513                        break;
1514                    }
1515                }
1516                ASYNC_COMMIT_RUNNING => {
1517                    if self
1518                        .commit_async_state
1519                        .compare_exchange(
1520                            ASYNC_COMMIT_RUNNING,
1521                            ASYNC_COMMIT_DIRTY,
1522                            Ordering::AcqRel,
1523                            Ordering::Acquire,
1524                        )
1525                        .is_ok()
1526                    {
1527                        return;
1528                    }
1529                }
1530                ASYNC_COMMIT_DIRTY => return,
1531                _ => unreachable!("invalid async commit state"),
1532            }
1533        }
1534
1535        let route = self.commit_route();
1536        let commit_identity = Arc::clone(&self.commit_identity);
1537        let commit_serialization = Arc::clone(&self.commit_serialization);
1538        let offsets = Arc::clone(&self.next_offsets);
1539        let positions = Arc::clone(&self.positions);
1540        let commit_async_state = Arc::clone(&self.commit_async_state);
1541        let auto_commit = self.auto_commit.clone();
1542        let callbacks = Arc::clone(&self.commit_async_callbacks);
1543        tokio::spawn(async move {
1544            loop {
1545                {
1546                    let _commit_guard = commit_serialization.lock().await;
1547                    // Calls queued before this snapshot are represented by the
1548                    // current offsets, so collapse them into this request.
1549                    commit_async_state.store(ASYNC_COMMIT_RUNNING, Ordering::Release);
1550                    let waiting = take_callbacks(&callbacks);
1551                    let (sent, (generation, member_id)) = snapshot_commit_offsets(
1552                        &commit_identity,
1553                        &offsets,
1554                        &positions,
1555                        auto_commit.as_ref(),
1556                    )
1557                    .await;
1558                    // Kafka completes an asynchronous commit of no offsets
1559                    // locally with success.
1560                    let result = if sent.is_empty() {
1561                        Ok(())
1562                    } else {
1563                        let response = route
1564                            .send(build_commit_topics(sent.clone()), generation, &member_id)
1565                            .await;
1566                        async_commit_result(
1567                            response,
1568                            &route.group_id,
1569                            route.group_instance_id.as_deref(),
1570                        )
1571                    };
1572                    // Kafka's `DefaultOffsetCommitCallback` logs a failed
1573                    // asynchronous commit, also one with a retriable error.
1574                    if let Err(error) = &result {
1575                        tracing::warn!(%error, "commit_async failed");
1576                    }
1577                    for callback in waiting {
1578                        // A panic in one callback must not stop the worker in
1579                        // the running state, which would disable later
1580                        // asynchronous commits.
1581                        let call = panic::AssertUnwindSafe(|| {
1582                            callback(&sent, result.as_ref().copied());
1583                        });
1584                        if panic::catch_unwind(call).is_err() {
1585                            tracing::error!("an offset commit callback panicked");
1586                        }
1587                    }
1588                }
1589
1590                if commit_async_state
1591                    .compare_exchange(
1592                        ASYNC_COMMIT_RUNNING,
1593                        ASYNC_COMMIT_IDLE,
1594                        Ordering::AcqRel,
1595                        Ordering::Acquire,
1596                    )
1597                    .is_ok()
1598                {
1599                    return;
1600                }
1601                // A caller marked the worker dirty during the RPC. Claim that
1602                // single coalesced follow-up and rejoin the FIFO behind any
1603                // synchronous commit already waiting on the mutex.
1604                if commit_async_state
1605                    .compare_exchange(
1606                        ASYNC_COMMIT_DIRTY,
1607                        ASYNC_COMMIT_RUNNING,
1608                        Ordering::AcqRel,
1609                        Ordering::Acquire,
1610                    )
1611                    .is_err()
1612                {
1613                    return;
1614                }
1615            }
1616        });
1617    }
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622    use std::{
1623        error::Error,
1624        future,
1625        sync::atomic::{AtomicBool, AtomicI32, AtomicUsize},
1626        time::Duration,
1627    };
1628
1629    use assert2::check;
1630    use coordinator::CoordinatorRetryPolicy;
1631    use krabka_client_core::{
1632        Client, MockBroker, MockReply,
1633        mock_test_support::{group_api_versions, offset_commit_response},
1634    };
1635    use krabka_protocol::{
1636        Encode, UnknownTaggedFields,
1637        owned::{
1638            api_versions_request, find_coordinator_request, metadata_request,
1639            offset_commit_request,
1640            offset_commit_request::{OffsetCommitRequestPartition, OffsetCommitRequestTopic},
1641            offset_commit_response::{
1642                OffsetCommitResponse, OffsetCommitResponsePartition, OffsetCommitResponseTopic,
1643            },
1644        },
1645        primitives::uuid::Uuid,
1646    };
1647    use krabka_units::secs;
1648    use tokio::{
1649        sync::{Notify, oneshot},
1650        task::yield_now,
1651        time,
1652        time::{advance, timeout},
1653    };
1654
1655    use super::*;
1656    use crate::subscription::shared;
1657
1658    fn response(errors: &[i16]) -> OffsetCommitResponse {
1659        OffsetCommitResponse {
1660            throttle_time_ms: 0,
1661            topics: vec![OffsetCommitResponseTopic {
1662                name: "topic".into(),
1663                topic_id: Uuid::ZERO,
1664                partitions: errors
1665                    .iter()
1666                    .enumerate()
1667                    .map(
1668                        |(partition_index, error_code)| OffsetCommitResponsePartition {
1669                            partition_index: i32::try_from(partition_index).unwrap(),
1670                            error_code: *error_code,
1671                            unknown_tagged_fields: UnknownTaggedFields::default(),
1672                        },
1673                    )
1674                    .collect(),
1675                unknown_tagged_fields: UnknownTaggedFields::default(),
1676            }],
1677            unknown_tagged_fields: UnknownTaggedFields::default(),
1678        }
1679    }
1680
1681    async fn selected_commit(consumer: &Consumer, offset: i64) -> Result<(), ConsumerError> {
1682        consumer
1683            .commit_offsets_sync(
1684                maplit::hashmap! {("topic".into(), 0) => OffsetAndMetadata::new(offset)},
1685            )
1686            .await
1687    }
1688
1689    async fn wait_for_async_commit_idle(consumer: &Consumer, timeout: Duration) {
1690        time::timeout(timeout, async {
1691            while consumer.commit_async_state.load(Ordering::Acquire) != ASYNC_COMMIT_IDLE {
1692                yield_now().await;
1693            }
1694        })
1695        .await
1696        .expect("async commit worker becomes idle");
1697    }
1698
1699    fn request_string_end(body: &[u8], start: usize) -> usize {
1700        let len = i16::from_be_bytes([body[start], body[start + 1]]);
1701        start + 2 + usize::try_from(len.max(0)).unwrap()
1702    }
1703
1704    fn request_string(body: &[u8], start: usize) -> (usize, String) {
1705        let end = request_string_end(body, start);
1706        (
1707            end,
1708            String::from_utf8(body[start + 2..end].to_vec()).unwrap(),
1709        )
1710    }
1711
1712    fn request_generation(body: &[u8]) -> i32 {
1713        let group_start = request_string_end(body, 0);
1714        let generation_start = request_string_end(body, group_start);
1715        i32::from_be_bytes(
1716            body[generation_start..generation_start + 4]
1717                .try_into()
1718                .unwrap(),
1719        )
1720    }
1721
1722    fn request_member_id(body: &[u8]) -> String {
1723        let group_start = request_string_end(body, 0);
1724        let generation_start = request_string_end(body, group_start);
1725        let (_, member_id) = request_string(body, generation_start + 4);
1726        member_id
1727    }
1728
1729    fn request_offsets(body: &[u8]) -> Vec<(i32, i64)> {
1730        fn i32_at(body: &[u8], start: usize) -> i32 {
1731            i32::from_be_bytes(body[start..start + 4].try_into().unwrap())
1732        }
1733        fn i64_at(body: &[u8], start: usize) -> i64 {
1734            i64::from_be_bytes(body[start..start + 8].try_into().unwrap())
1735        }
1736
1737        let mut cursor = request_string_end(body, 0);
1738        cursor = request_string_end(body, cursor);
1739        cursor += 4;
1740        cursor = request_string_end(body, cursor);
1741        cursor += 8;
1742        let topic_count = usize::try_from(i32_at(body, cursor)).unwrap();
1743        cursor += 4;
1744        let mut offsets = Vec::new();
1745        for _ in 0..topic_count {
1746            cursor = request_string_end(body, cursor);
1747            let partition_count = usize::try_from(i32_at(body, cursor)).unwrap();
1748            cursor += 4;
1749            for _ in 0..partition_count {
1750                offsets.push((i32_at(body, cursor), i64_at(body, cursor + 4)));
1751                cursor += 12;
1752                cursor = request_string_end(body, cursor);
1753            }
1754        }
1755        offsets
1756    }
1757
1758    fn commit_identity(generation: i32, member_id: &str) -> Arc<Mutex<CommitIdentity>> {
1759        Arc::new(Mutex::new(CommitIdentity {
1760            generation,
1761            member_id: member_id.into(),
1762            ownership_ids: maplit::hashmap! {("topic".into(), 0) => 1},
1763            rejoin_on_poll: false,
1764        }))
1765    }
1766
1767    fn advance_generation_on_first_commit()
1768    -> impl Fn(&Arc<Mutex<CommitIdentity>>, &Arc<AtomicI32>, i32, &str) + Send + 'static {
1769        |identity, generation, _request_generation, _request_member_id| {
1770            identity.try_lock().unwrap().generation = 8;
1771            generation.store(8, Ordering::Relaxed);
1772        }
1773    }
1774
1775    #[derive(bon::Builder)]
1776    struct SelectedCommitConsumerStart<F> {
1777        #[builder(default = commit_identity(7, "member-a"))]
1778        commit_identity: Arc<Mutex<CommitIdentity>>,
1779        #[builder(default = Arc::new(Notify::new()))]
1780        assignment_changed: Arc<Notify>,
1781        #[builder(default = Arc::new(AtomicI32::new(7)))]
1782        generation: Arc<AtomicI32>,
1783        #[builder(default = Arc::new(AtomicUsize::new(0)))]
1784        requests: Arc<AtomicUsize>,
1785        #[builder(default = false)]
1786        mixed_response: bool,
1787        on_first_request: F,
1788    }
1789
1790    struct SelectedCommitConsumer {
1791        consumer: Consumer,
1792        mock: MockBroker,
1793        requests: Arc<AtomicUsize>,
1794        seen_offsets: Arc<StdMutex<Vec<(i32, i64)>>>,
1795    }
1796
1797    impl<F> SelectedCommitConsumerStart<F>
1798    where
1799        F: Fn(&Arc<Mutex<CommitIdentity>>, &Arc<AtomicI32>, i32, &str) + Send + 'static,
1800    {
1801        async fn start(self) -> SelectedCommitConsumer {
1802            let Self {
1803                commit_identity,
1804                assignment_changed,
1805                generation,
1806                requests,
1807                mixed_response,
1808                on_first_request,
1809            } = self;
1810            let identity_in_mock = Arc::clone(&commit_identity);
1811            let generation_in_mock = Arc::clone(&generation);
1812            let changed_in_mock = Arc::clone(&assignment_changed);
1813            let requests_in_mock = Arc::clone(&requests);
1814            let seen_offsets = Arc::new(StdMutex::new(Vec::new()));
1815            let seen_offsets_in_mock = Arc::clone(&seen_offsets);
1816            let mock = MockBroker::start(move |api_key, version, _corr_id, body| {
1817                if api_key == api_versions_request::API_KEY {
1818                    return Some(
1819                        group_api_versions(offset_commit_request::API_KEY, (2, 2)).unwrap(),
1820                    );
1821                }
1822                if api_key != offset_commit_request::API_KEY {
1823                    return None;
1824                }
1825                let attempt = requests_in_mock.fetch_add(1, Ordering::SeqCst);
1826                let offsets = request_offsets(body);
1827                seen_offsets_in_mock.lock().unwrap().extend(&offsets);
1828                let partitions = offsets
1829                    .iter()
1830                    .map(|(partition, _)| *partition)
1831                    .collect::<Vec<_>>();
1832                if attempt == 0 {
1833                    on_first_request(
1834                        &identity_in_mock,
1835                        &generation_in_mock,
1836                        request_generation(body),
1837                        &request_member_id(body),
1838                    );
1839                    changed_in_mock.notify_waiters();
1840                    let errors = if mixed_response {
1841                        vec![0, 27]
1842                    } else {
1843                        vec![27]
1844                    };
1845                    Some(test_support::encode_protocol_response(
1846                        &response(&errors),
1847                        version,
1848                    ))
1849                } else {
1850                    let errors = if mixed_response && partitions != [1] {
1851                        vec![42; partitions.len()]
1852                    } else {
1853                        vec![0; partitions.len()]
1854                    };
1855                    let mut response = response(&errors);
1856                    for (partition, partition_index) in
1857                        response.topics[0].partitions.iter_mut().zip(partitions)
1858                    {
1859                        partition.partition_index = partition_index;
1860                    }
1861                    Some(test_support::encode_protocol_response(&response, version))
1862                }
1863            })
1864            .await
1865            .unwrap();
1866            let consumer = commit_consumer(
1867                CommitConsumerStart::builder()
1868                    .mock(&mock)
1869                    .commit_identity(commit_identity)
1870                    .assignment_changed(assignment_changed)
1871                    .generation(generation)
1872                    .build(),
1873            )
1874            .await;
1875            SelectedCommitConsumer {
1876                consumer,
1877                mock,
1878                requests,
1879                seen_offsets,
1880            }
1881        }
1882    }
1883
1884    #[derive(Clone, Copy)]
1885    enum AutoCommitMockResponse {
1886        Success,
1887        RetryOnce,
1888    }
1889
1890    struct AutoCommitCoordinator {
1891        mock: MockBroker,
1892        requests: Arc<AtomicUsize>,
1893    }
1894
1895    async fn auto_commit_coordinator(
1896        response_mode: AutoCommitMockResponse,
1897    ) -> AutoCommitCoordinator {
1898        let requests = Arc::new(AtomicUsize::new(0));
1899        let requests_in_mock = Arc::clone(&requests);
1900        let mock = MockBroker::start(move |api_key, version, _corr_id, _body| {
1901            if api_key == api_versions_request::API_KEY {
1902                return Some(group_api_versions(offset_commit_request::API_KEY, (2, 2)).unwrap());
1903            }
1904            if api_key != offset_commit_request::API_KEY {
1905                return None;
1906            }
1907            let attempt = requests_in_mock.fetch_add(1, Ordering::SeqCst);
1908            let error_code = match (response_mode, attempt) {
1909                (AutoCommitMockResponse::RetryOnce, 0) => 7,
1910                _ => 0,
1911            };
1912            Some(test_support::encode_protocol_response(
1913                &response(&[error_code]),
1914                version,
1915            ))
1916        })
1917        .await
1918        .unwrap();
1919        AutoCommitCoordinator { mock, requests }
1920    }
1921
1922    /// A consumer whose client talks to `mock`, which owns each partition of
1923    /// `commit_identity` at next offset 12.
1924    #[derive(bon::Builder)]
1925    #[builder(start_fn = builder, finish_fn = build)]
1926    struct CommitConsumerStart<'a> {
1927        mock: &'a MockBroker,
1928        commit_identity: Arc<Mutex<CommitIdentity>>,
1929        #[builder(default = Arc::new(Notify::new()))]
1930        assignment_changed: Arc<Notify>,
1931        #[builder(default = Arc::new(AtomicI32::new(7)))]
1932        generation: Arc<AtomicI32>,
1933    }
1934
1935    async fn commit_consumer(start: CommitConsumerStart<'_>) -> Consumer {
1936        let client = Client::builder()
1937            .bootstrap(start.mock.addr.to_string())
1938            .build()
1939            .await
1940            .unwrap();
1941        let ownership = start.commit_identity.lock().await.ownership_ids.clone();
1942        let assigned = ownership.keys().cloned().collect::<Vec<_>>();
1943        let next_offsets = ownership
1944            .keys()
1945            .cloned()
1946            .map(|partition| (partition, 12))
1947            .collect::<HashMap<_, i64>>();
1948        Consumer::test_builder(client)
1949            .commit_identity(start.commit_identity)
1950            .current_generation(start.generation)
1951            .assigned(assigned)
1952            .assignment_changed(start.assignment_changed)
1953            .next_offsets(next_offsets)
1954            .build()
1955    }
1956
1957    async fn default_commit_consumer(mock: &MockBroker) -> Consumer {
1958        let start = CommitConsumerStart::builder()
1959            .mock(mock)
1960            .commit_identity(commit_identity(7, "member-a"))
1961            .build();
1962        commit_consumer(start).await
1963    }
1964
1965    /// Kafka's `OffsetAndMetadata` rejects a negative offset, and Kafka's
1966    /// `commitSync(offsets)` accepts an offset past the consumed position.
1967    #[test]
1968    fn selected_offset_validation_follows_kafka() {
1969        let assigned = maplit::hashmap! {("topic".to_owned(), 2) => 1};
1970
1971        let mut actual = Vec::new();
1972        let mut wanted = Vec::new();
1973        for (name, partition, offset, expected) in [
1974            (
1975                "negative",
1976                ("topic", 2),
1977                -1,
1978                Some("invalid seek offset -1: must be non-negative"),
1979            ),
1980            (
1981                "unassigned",
1982                ("other", 2),
1983                1,
1984                Some("illegal state: cannot commit unassigned partition other-2"),
1985            ),
1986            ("at the position", ("topic", 2), 11, None),
1987            ("past the position", ("topic", 2), 100, None),
1988        ] {
1989            let offsets = maplit::hashmap! {(partition.0.to_owned(), partition.1) => OffsetAndMetadata::new(offset)};
1990            let result = selected_offsets_with_ownership(offsets, &assigned).err();
1991            actual.push((name, result.map(|error| error.to_string())));
1992            wanted.push((name, expected.map(str::to_owned)));
1993        }
1994        assert2::assert!(actual == wanted);
1995    }
1996
1997    #[test]
1998    fn commit_offsets_use_position_epoch_or_unknown_epoch() {
1999        let mut raw = HashMap::new();
2000        raw.insert(("known".into(), 0), 11);
2001        raw.insert(("unknown".into(), 1), 22);
2002
2003        let mut positions = HashMap::new();
2004        positions.insert(
2005            ("known".into(), 0),
2006            PartitionPosition {
2007                offset_epoch: krabka_ids::LeaderEpoch(7),
2008                ..Default::default()
2009            },
2010        );
2011
2012        let offsets = commit_offsets(raw, &positions);
2013        assert2::assert!(
2014            offsets
2015                == maplit::hashmap! {("known".into(), 0) => (11, 7), ("unknown".into(), 1) => (22, -1)}
2016        );
2017    }
2018
2019    /// Kafka's `SubscriptionState.allConsumed` commits each owned partition
2020    /// that has a valid position, with its leader epoch. A later commit sends a
2021    /// position only while its ownership is still current.
2022    #[test]
2023    fn consumed_positions_keep_owned_valid_positions_of_the_current_ownership() {
2024        let ownership_ids = maplit::hashmap! {("orders".into(), 0) => 1, ("orders".into(), 1) => 2, ("orders".into(), 2) => 3, ("orders".into(), 3) => 4};
2025        let next_offsets = maplit::hashmap! {("orders".into(), 0) => 12, ("orders".into(), 1) => 0, ("orders".into(), 2) => i64::MAX, ("orders".into(), 3) => -1, ("unowned".into(), 0) => 5};
2026        let positions = maplit::hashmap! {("orders".into(), 0) => PartitionPosition {
2027            offset_epoch: krabka_ids::LeaderEpoch(4),
2028            ..Default::default()
2029        }};
2030
2031        let consumed = all_consumed(&ownership_ids, &next_offsets, &positions);
2032        assert2::assert!(
2033            consumed
2034                == maplit::hashmap! {("orders".into(), 0) => ConsumedPosition {
2035                    offset: 12,
2036                    leader_epoch: 4,
2037                    ownership_id: 1,
2038                }, ("orders".into(), 1) => ConsumedPosition {
2039                    offset: 0,
2040                    leader_epoch: -1,
2041                    ownership_id: 2,
2042                }}
2043        );
2044
2045        let reassigned = maplit::hashmap! {("orders".into(), 0) => 9, ("orders".into(), 1) => 2};
2046        assert2::assert!(
2047            owned_offsets(&consumed, &reassigned)
2048                == maplit::hashmap! {("orders".into(), 1) => (0, -1)}
2049        );
2050    }
2051
2052    /// Kafka's `OffsetCommitResponseHandler` raises a retriable error for 3, 7,
2053    /// 14, 15 and 16, and for a failed connection. Every other error is final.
2054    #[test]
2055    fn auto_commit_outcome_follows_kafka_commit_error_classes() {
2056        let mut actual = Vec::new();
2057        let mut wanted = Vec::new();
2058        for (name, result, expected) in [
2059            (
2060                "success",
2061                Ok(response(&[0, 0])),
2062                AutoCommitOutcome::Committed,
2063            ),
2064            (
2065                "unknown topic or partition",
2066                Ok(response(&[0, 3])),
2067                AutoCommitOutcome::Retriable,
2068            ),
2069            (
2070                "request timed out",
2071                Ok(response(&[7])),
2072                AutoCommitOutcome::Retriable,
2073            ),
2074            (
2075                "coordinator loading",
2076                Ok(response(&[14])),
2077                AutoCommitOutcome::Retriable,
2078            ),
2079            (
2080                "coordinator not available",
2081                Ok(response(&[15])),
2082                AutoCommitOutcome::Retriable,
2083            ),
2084            (
2085                "not coordinator",
2086                Ok(response(&[16])),
2087                AutoCommitOutcome::Retriable,
2088            ),
2089            (
2090                "illegal generation",
2091                Ok(response(&[22])),
2092                AutoCommitOutcome::Failed,
2093            ),
2094            (
2095                "rebalance in progress",
2096                Ok(response(&[27])),
2097                AutoCommitOutcome::Failed,
2098            ),
2099            (
2100                "group authorization failed",
2101                Ok(response(&[30])),
2102                AutoCommitOutcome::Failed,
2103            ),
2104            (
2105                "disconnected",
2106                Err(ConsumerError::Client(ClientError::Disconnected)),
2107                AutoCommitOutcome::Retriable,
2108            ),
2109            (
2110                "client timeout",
2111                Err(ConsumerError::Client(ClientError::Timeout(secs(30)))),
2112                AutoCommitOutcome::Retriable,
2113            ),
2114            (
2115                "coordinator lookup not available",
2116                Err(ConsumerError::Server(15)),
2117                AutoCommitOutcome::Retriable,
2118            ),
2119            (
2120                "coordinator lookup refused",
2121                Err(ConsumerError::Server(30)),
2122                AutoCommitOutcome::Failed,
2123            ),
2124            (
2125                "topic authorization failed",
2126                Ok(response(&[0, 29])),
2127                AutoCommitOutcome::Failed,
2128            ),
2129            (
2130                "retriable partition, then a fatal partition",
2131                Ok(response(&[3, 30])),
2132                AutoCommitOutcome::Failed,
2133            ),
2134            (
2135                "fatal partition, then a retriable partition",
2136                Ok(response(&[30, 3])),
2137                AutoCommitOutcome::Failed,
2138            ),
2139            (
2140                "retriable coordinator partition, then a fatal partition",
2141                Ok(response(&[16, 22])),
2142                AutoCommitOutcome::Failed,
2143            ),
2144            (
2145                "topic authorization failure, then a retriable partition",
2146                Ok(response(&[29, 3])),
2147                AutoCommitOutcome::Retriable,
2148            ),
2149            (
2150                "retriable partition, then a topic authorization failure",
2151                Ok(response(&[3, 29])),
2152                AutoCommitOutcome::Retriable,
2153            ),
2154        ] {
2155            actual.push((name, auto_commit_outcome(&result)));
2156            wanted.push((name, expected));
2157        }
2158        assert2::assert!(actual == wanted);
2159    }
2160
2161    /// The automatic commit route finds the coordinator again and resends only
2162    /// when the response is retriable because of `7`, `15` or `16`, the codes
2163    /// where Kafka's `OffsetCommitResponseHandler` calls
2164    /// `markCoordinatorUnknown`. A final error or a rebalance error on another
2165    /// partition takes precedence.
2166    #[test]
2167    fn names_moved_coordinator_only_for_a_retriable_coordinator_response() {
2168        let mut actual = Vec::new();
2169        let mut wanted = Vec::new();
2170        for (name, errors, expected) in [
2171            ("success", &[0, 0][..], false),
2172            ("request timed out", &[0, 7][..], true),
2173            ("coordinator not available", &[15][..], true),
2174            ("not coordinator with a retriable code", &[3, 16][..], true),
2175            ("coordinator load in progress", &[14][..], false),
2176            ("unknown topic or partition", &[3][..], false),
2177            (
2178                "request timed out and metadata too large",
2179                &[7, 12][..],
2180                false,
2181            ),
2182            (
2183                "not coordinator and group authorization",
2184                &[30, 16][..],
2185                false,
2186            ),
2187            ("not coordinator and a rebalance", &[16, 27][..], false),
2188            (
2189                "not coordinator and topic authorization",
2190                &[29, 16][..],
2191                true,
2192            ),
2193        ] {
2194            actual.push((name, names_moved_coordinator(&response(errors))));
2195            wanted.push((name, expected));
2196        }
2197        assert2::assert!(actual == wanted);
2198    }
2199
2200    /// `poll` does not wait for a commit that holds the commit lock. The
2201    /// interval commit stays due, and the next `poll` sends it.
2202    #[tokio::test(start_paused = true)]
2203    async fn busy_commit_lock_moves_the_interval_commit_to_the_next_poll() {
2204        let coordinator = auto_commit_coordinator(AutoCommitMockResponse::Success).await;
2205        let mut consumer = commit_consumer(
2206            CommitConsumerStart::builder()
2207                .mock(&coordinator.mock)
2208                .commit_identity(commit_identity(7, "member-a"))
2209                .build(),
2210        )
2211        .await;
2212        let interval = Duration::from_hours(1);
2213        consumer.auto_commit = Some(AutoCommit::new(interval));
2214        advance(interval).await;
2215
2216        let busy = consumer.commit_serialization.lock().await;
2217        consumer.maybe_auto_commit_async().await;
2218        let while_busy = coordinator.requests.load(Ordering::SeqCst);
2219        drop(busy);
2220        consumer.maybe_auto_commit_async().await;
2221        drop(consumer.commit_serialization.lock().await);
2222        let after = coordinator.requests.load(Ordering::SeqCst);
2223
2224        coordinator.mock.stop();
2225        assert2::assert!((while_busy, after) == (0, 1));
2226    }
2227
2228    /// After a retriable failure, Kafka's `autoCommitOffsetsAsync` makes the
2229    /// next auto commit due after `retry.backoff.ms`, and not after a full
2230    /// `auto.commit.interval.ms`.
2231    #[tokio::test(start_paused = true)]
2232    async fn retriable_auto_commit_failure_commits_again_after_the_retry_backoff() {
2233        let coordinator = auto_commit_coordinator(AutoCommitMockResponse::RetryOnce).await;
2234        let mut consumer = commit_consumer(
2235            CommitConsumerStart::builder()
2236                .mock(&coordinator.mock)
2237                .commit_identity(commit_identity(7, "member-a"))
2238                .build(),
2239        )
2240        .await;
2241        let interval = Duration::from_hours(1);
2242        consumer.auto_commit = Some(AutoCommit::new(interval));
2243        let backoff = consumer.retry_policy.initial_backoff;
2244        // The paused clock jumps to the next timer while a response is on the
2245        // loopback socket. A short timer keeps each jump far below the request
2246        // timeout.
2247        let ticker = tokio::spawn(async {
2248            loop {
2249                sleep(Duration::from_millis(1)).await;
2250            }
2251        });
2252
2253        let mut sent = Vec::new();
2254        for advance in [interval, Duration::ZERO, backoff, Duration::ZERO] {
2255            time::advance(advance).await;
2256            consumer.maybe_auto_commit_async().await;
2257            drop(consumer.commit_serialization.lock().await);
2258            sent.push(coordinator.requests.load(Ordering::SeqCst));
2259        }
2260
2261        ticker.abort();
2262        coordinator.mock.stop();
2263        assert2::assert!(sent == vec![1, 1, 2, 2]);
2264    }
2265
2266    #[tokio::test]
2267    async fn snapshot_commit_offsets_is_empty_without_offsets() {
2268        let identity = commit_identity(7, "member-a");
2269        let offsets = Arc::new(Mutex::new(HashMap::new()));
2270        let positions = Arc::new(Mutex::new(HashMap::new()));
2271
2272        let snapshot = snapshot_commit_offsets(&identity, &offsets, &positions, None).await;
2273
2274        assert2::assert!(snapshot == (HashMap::new(), (7, "member-a".into())));
2275    }
2276
2277    #[tokio::test]
2278    async fn snapshot_commit_offsets_preserves_offsets_and_epochs() {
2279        let identity = Arc::new(Mutex::new(CommitIdentity {
2280            generation: 7,
2281            member_id: "member-a".into(),
2282            ownership_ids: maplit::hashmap! {("alpha".into(), 0) => 1, ("alpha".into(), 1) => 2},
2283            rejoin_on_poll: false,
2284        }));
2285        let offsets = Arc::new(Mutex::new(
2286            maplit::hashmap! {("alpha".to_owned(), 0) => 10, ("alpha".to_owned(), 1) => 20},
2287        ));
2288        let positions = Arc::new(Mutex::new(
2289            maplit::hashmap! {("alpha".to_owned(), 1) => PartitionPosition {
2290                offset_epoch: krabka_ids::LeaderEpoch(7),
2291                ..Default::default()
2292            }},
2293        ));
2294        let snapshot = snapshot_commit_offsets(&identity, &offsets, &positions, None).await;
2295
2296        assert2::assert!(
2297            snapshot
2298                == (
2299                    maplit::hashmap! {("alpha".to_owned(), 0) => OffsetAndMetadata::new(10), ("alpha".to_owned(), 1) => OffsetAndMetadata {
2300                        offset: 20,
2301                        leader_epoch: Some(7),
2302                        metadata: String::new(),
2303                    }},
2304                    (7, "member-a".into())
2305                )
2306        );
2307    }
2308
2309    #[test]
2310    fn build_commit_request_preserves_group_member_generation_and_topics() {
2311        let topics = vec![OffsetCommitRequestTopic {
2312            name: "topic".into(),
2313            topic_id: Uuid::ZERO,
2314            partitions: vec![OffsetCommitRequestPartition {
2315                partition_index: 3,
2316                committed_offset: 99,
2317                committed_leader_epoch: 5,
2318                committed_metadata: Some(String::new()),
2319                unknown_tagged_fields: UnknownTaggedFields::default(),
2320            }],
2321            unknown_tagged_fields: UnknownTaggedFields::default(),
2322        }];
2323
2324        let req = build_commit_request(
2325            "group-a".into(),
2326            42,
2327            "member-a".into(),
2328            Some("instance-a".into()),
2329            topics.clone(),
2330        );
2331
2332        assert2::assert!(
2333            req == TopicNameOffsetCommit::new(OffsetCommitRequest {
2334                group_id: "group-a".into(),
2335                generation_id_or_member_epoch: 42,
2336                member_id: "member-a".into(),
2337                group_instance_id: Some("instance-a".into()),
2338                retention_time_ms: -1,
2339                topics,
2340                unknown_tagged_fields: UnknownTaggedFields::default(),
2341            })
2342        );
2343    }
2344
2345    /// `commit_response_outcome` gives a final error precedence over a
2346    /// rebalance-class error, a rebalance-class error over a retriable error,
2347    /// and a retriable error over `TOPIC_AUTHORIZATION_FAILED`, where Kafka's
2348    /// result depends on the partition order. Whether a rebalance-class error
2349    /// raises `CommitFailed` or `RebalanceInProgress` depends on the code, the
2350    /// generation and the coordinator task, exactly as Kafka's
2351    /// `commitOffsetsSync` never retries either and raises the matching
2352    /// exception straight through.
2353    #[test]
2354    fn commit_response_outcome_orders_errors_and_reads_the_consumer_state() {
2355        let running = CommitResponseContext {
2356            group_id: "group-a",
2357            group_instance_id: Some("instance-a"),
2358            coordinator_alive: true,
2359            generation_unchanged: true,
2360        };
2361        let stopped = CommitResponseContext {
2362            coordinator_alive: false,
2363            ..running
2364        };
2365        let rejoined = CommitResponseContext {
2366            generation_unchanged: false,
2367            ..running
2368        };
2369        let rejoined_and_stopped = CommitResponseContext {
2370            coordinator_alive: false,
2371            ..rejoined
2372        };
2373        let commit_failed = ConsumerError::CommitFailed.to_string();
2374        let rebalance_in_progress =
2375            ConsumerError::RebalanceInProgress("group-a".into()).to_string();
2376        let mut actual = Vec::new();
2377        let mut wanted = Vec::new();
2378        for (name, errors, context, expected) in [
2379            (
2380                "success",
2381                &[0, 0][..],
2382                running,
2383                Ok(CommitOutcome::Acked(
2384                    maplit::hashset! {("topic".into(), 0), ("topic".into(), 1)},
2385                )),
2386            ),
2387            (
2388                "retriable code keeps the acknowledged partitions",
2389                &[0, 3][..],
2390                running,
2391                Ok(CommitOutcome::Retriable {
2392                    code: 3,
2393                    acknowledged: maplit::hashset! {("topic".into(), 0)},
2394                    find_coordinator: false,
2395                }),
2396            ),
2397            (
2398                "a later coordinator code finds the coordinator",
2399                &[14, 7][..],
2400                running,
2401                Ok(CommitOutcome::Retriable {
2402                    code: 14,
2403                    acknowledged: HashSet::new(),
2404                    find_coordinator: true,
2405                }),
2406            ),
2407            (
2408                "a rebalance error takes precedence over a retriable code",
2409                &[3, 27][..],
2410                running,
2411                Err(rebalance_in_progress.clone()),
2412            ),
2413            (
2414                "final code takes precedence over a rebalance error",
2415                &[27, 42][..],
2416                running,
2417                Err(ConsumerError::Server(42).to_string()),
2418            ),
2419            (
2420                "group authorization takes precedence over a rebalance error",
2421                &[22, 30][..],
2422                running,
2423                Err("not authorized to access group: group-a".into()),
2424            ),
2425            (
2426                "retriable code takes precedence over topic authorization",
2427                &[29, 16][..],
2428                running,
2429                Ok(CommitOutcome::Retriable {
2430                    code: 16,
2431                    acknowledged: HashSet::new(),
2432                    find_coordinator: true,
2433                }),
2434            ),
2435            (
2436                "topic authorization after a success",
2437                &[0, 29][..],
2438                running,
2439                Err("not authorized to access topics: [topic]".into()),
2440            ),
2441            (
2442                "illegal generation with the request's own generation still current: a real kick",
2443                &[22][..],
2444                running,
2445                Err(commit_failed.clone()),
2446            ),
2447            (
2448                "unknown member id with the request's own generation still current: a real kick",
2449                &[25][..],
2450                running,
2451                Err(commit_failed.clone()),
2452            ),
2453            (
2454                "rebalance in progress",
2455                &[27][..],
2456                running,
2457                Err(rebalance_in_progress.clone()),
2458            ),
2459            (
2460                "illegal generation after a rejoin already moved the generation on: a stale response",
2461                &[22][..],
2462                rejoined,
2463                Err(rebalance_in_progress.clone()),
2464            ),
2465            (
2466                "unknown member id after a rejoin already moved the generation on: a stale response",
2467                &[25][..],
2468                rejoined,
2469                Err(rebalance_in_progress.clone()),
2470            ),
2471            (
2472                "rebalance in progress after a rejoin is still a rebalance error",
2473                &[27][..],
2474                rejoined,
2475                Err(rebalance_in_progress.clone()),
2476            ),
2477            (
2478                "unknown member id after the task stopped, generation still current",
2479                &[25][..],
2480                stopped,
2481                Err(commit_failed.clone()),
2482            ),
2483            (
2484                "unknown member id after the task stopped and a rejoin: no task left to rejoin on",
2485                &[25][..],
2486                CommitResponseContext {
2487                    generation_unchanged: false,
2488                    coordinator_alive: false,
2489                    ..running
2490                },
2491                Err(commit_failed.clone()),
2492            ),
2493            (
2494                "rebalance in progress after the task stopped",
2495                &[27][..],
2496                stopped,
2497                Err(commit_failed.clone()),
2498            ),
2499            (
2500                "fenced instance id with the same generation",
2501                &[82][..],
2502                running,
2503                Err(ConsumerError::FencedInstanceId("instance-a".into()).to_string()),
2504            ),
2505            (
2506                "fenced instance id after a rejoin already moved the generation on",
2507                &[82][..],
2508                rejoined,
2509                Err(rebalance_in_progress),
2510            ),
2511            (
2512                "fenced instance id after a rejoin and a task stop",
2513                &[82][..],
2514                rejoined_and_stopped,
2515                Err(commit_failed),
2516            ),
2517        ] {
2518            actual.push((
2519                name,
2520                commit_response_outcome(&response(errors), context)
2521                    .map_err(|error| error.to_string()),
2522            ));
2523            wanted.push((name, expected));
2524        }
2525        assert2::assert!(actual == wanted);
2526    }
2527
2528    /// The negotiated `OffsetCommit` version and the request that the
2529    /// coordinator decoded at that version.
2530    #[derive(Clone, Debug, PartialEq)]
2531    struct VersionedOffsetCommit {
2532        version: i16,
2533        request: OffsetCommitRequest,
2534    }
2535
2536    type SentOffsetCommits = Vec<VersionedOffsetCommit>;
2537
2538    #[derive(Clone, Copy)]
2539    struct OffsetCommitVersionRange {
2540        min: i16,
2541        max: i16,
2542    }
2543
2544    #[derive(bon::Builder)]
2545    struct OffsetCommitCoordinatorOptions {
2546        #[builder(default = 2)]
2547        min_version: i16,
2548        #[builder(default = 7)]
2549        max_version: i16,
2550        #[builder(default)]
2551        error_code: i16,
2552    }
2553
2554    struct OffsetCommitCoordinator {
2555        mock: MockBroker,
2556        requests: Arc<StdMutex<Vec<VersionedOffsetCommit>>>,
2557    }
2558
2559    impl OffsetCommitCoordinatorOptions {
2560        async fn start(self) -> OffsetCommitCoordinator {
2561            use bytes::Buf as _;
2562            use krabka_protocol::Decode as _;
2563
2564            let Self {
2565                min_version,
2566                max_version,
2567                error_code,
2568            } = self;
2569            let requests = Arc::new(StdMutex::new(Vec::new()));
2570            let requests_in_mock = Arc::clone(&requests);
2571            let mock = MockBroker::start(move |api_key, version, _corr_id, mut body| {
2572                if api_key == api_versions_request::API_KEY {
2573                    return Some(
2574                        group_api_versions(
2575                            offset_commit_request::API_KEY,
2576                            (min_version, max_version),
2577                        )
2578                        .unwrap(),
2579                    );
2580                }
2581                if api_key != offset_commit_request::API_KEY {
2582                    return None;
2583                }
2584                let client_id_len = usize::try_from(body.get_i16()).expect("client id length");
2585                body.advance(client_id_len);
2586                if version >= offset_commit_request::FLEXIBLE_MIN {
2587                    body.advance(1);
2588                }
2589                let request = OffsetCommitRequest::decode(&mut body, version)
2590                    .expect("offset commit request decodes");
2591                requests_in_mock
2592                    .lock()
2593                    .expect("requests lock")
2594                    .push(VersionedOffsetCommit {
2595                        version,
2596                        request: request.clone(),
2597                    });
2598                let response = offset_commit_response(&request, |_| error_code);
2599                let mut body = Vec::new();
2600                if version >= offset_commit_request::FLEXIBLE_MIN {
2601                    body.push(0);
2602                }
2603                body.extend(test_support::encode_protocol_response(&response, version));
2604                Some(body)
2605            })
2606            .await
2607            .unwrap();
2608            OffsetCommitCoordinator { mock, requests }
2609        }
2610    }
2611
2612    /// The result of `commit_offsets_sync`, with a version error as its
2613    /// ranges.
2614    type CommitResult = Result<(), (i16, i16, i16, i16, i16)>;
2615
2616    /// Apache Kafka's classic `ConsumerCoordinator.sendOffsetCommitRequest`
2617    /// builds `OffsetCommit` with `OffsetCommitRequest.Builder.forTopicNames`,
2618    /// which caps the version at 9. The request names each topic and carries
2619    /// no topic id at every negotiated version.
2620    #[tokio::test]
2621    async fn commit_sends_offset_commit_by_topic_name_at_v9_or_lower() {
2622        let sent_request = |version| {
2623            vec![VersionedOffsetCommit {
2624                version,
2625                request: OffsetCommitRequest {
2626                    group_id: "group-a".into(),
2627                    generation_id_or_member_epoch: 7,
2628                    member_id: "member-a".into(),
2629                    group_instance_id: None,
2630                    retention_time_ms: -1,
2631                    topics: vec![OffsetCommitRequestTopic {
2632                        name: "topic".into(),
2633                        topic_id: Uuid::ZERO,
2634                        partitions: vec![OffsetCommitRequestPartition {
2635                            partition_index: 0,
2636                            committed_offset: 12,
2637                            committed_leader_epoch: -1,
2638                            committed_metadata: Some(String::new()),
2639                            unknown_tagged_fields: UnknownTaggedFields::default(),
2640                        }],
2641                        unknown_tagged_fields: UnknownTaggedFields::default(),
2642                    }],
2643                    unknown_tagged_fields: UnknownTaggedFields::default(),
2644                },
2645            }]
2646        };
2647        for (name, offset_commit_range, expected_requests, expected_result) in [
2648            (
2649                "coordinator stops at v7",
2650                OffsetCommitVersionRange { min: 2, max: 7 },
2651                sent_request(7),
2652                Ok(()),
2653            ),
2654            (
2655                "coordinator stops at v9",
2656                OffsetCommitVersionRange { min: 2, max: 9 },
2657                sent_request(9),
2658                Ok(()),
2659            ),
2660            (
2661                "coordinator supports v10",
2662                OffsetCommitVersionRange { min: 2, max: 10 },
2663                sent_request(9),
2664                Ok(()),
2665            ),
2666            (
2667                "coordinator supports only v10",
2668                OffsetCommitVersionRange { min: 10, max: 10 },
2669                Vec::new(),
2670                Err((offset_commit_request::API_KEY, 10, 10, 2, 9)),
2671            ),
2672        ] {
2673            let OffsetCommitCoordinator { mock, requests } =
2674                OffsetCommitCoordinatorOptions::builder()
2675                    .min_version(offset_commit_range.min)
2676                    .max_version(offset_commit_range.max)
2677                    .build()
2678                    .start()
2679                    .await;
2680            let consumer = default_commit_consumer(&mock).await;
2681
2682            let result: CommitResult = consumer
2683                .commit_offsets_sync(
2684                    maplit::hashmap! {("topic".into(), 0) => OffsetAndMetadata::new(12)},
2685                )
2686                .await
2687                .map_err(|error| match error {
2688                    ConsumerError::Client(ClientError::IncompatibleVersion {
2689                        api_key,
2690                        broker_min,
2691                        broker_max,
2692                        client_min,
2693                        client_max,
2694                    }) => (api_key, broker_min, broker_max, client_min, client_max),
2695                    other => panic!("case {name}: unexpected error {other:?}"),
2696                });
2697
2698            mock.stop();
2699            let requests: SentOffsetCommits = requests.lock().expect("requests lock").clone();
2700            check!(
2701                (requests, result) == (expected_requests, expected_result),
2702                "case {name}"
2703            );
2704        }
2705    }
2706
2707    /// One scripted answer of the mock coordinator to an `OffsetCommit`.
2708    #[derive(Clone, Copy, Debug)]
2709    enum CommitAnswer {
2710        /// Answer each partition of a topic with the error code of that topic,
2711        /// and `0` for a topic that is not in the list.
2712        Codes(&'static [(&'static str, i16)]),
2713        /// Close the connection without a response.
2714        Close,
2715        /// Send no response, so the request times out.
2716        Silent,
2717    }
2718
2719    /// The result of a synchronous commit, the `OffsetCommit` requests and the
2720    /// `FindCoordinator` requests that the coordinator received. An error is
2721    /// [`described`].
2722    type CommitCodeResult = (Result<(), (String, Option<String>)>, usize, usize);
2723
2724    /// The message of a commit that times out after one second, such as a
2725    /// commit with a retriable error and a backoff past its timeout. Kafka's
2726    /// `commitSync` then throws `TimeoutException`, and the async consumer's
2727    /// `CommitRequestManager` gives it the last error as its cause.
2728    const COMMIT_TIMED_OUT: &str =
2729        "timeout: Timeout of 1000ms expired before successfully committing offsets";
2730
2731    /// A commit error as its message and the message of its cause, so that a
2732    /// test can compare the whole error.
2733    fn described(error: &ConsumerError) -> (String, Option<String>) {
2734        (
2735            error.to_string(),
2736            Error::source(error).map(ToString::to_string),
2737        )
2738    }
2739
2740    /// `commit_sync` handles each `OffsetCommit` error code as Kafka's
2741    /// `ConsumerCoordinator.OffsetCommitResponseHandler.handle` and
2742    /// `commitOffsetsSync` do. Retriable codes and failed requests retry until
2743    /// the timeout. `7`, `15`, `16` and a failed request find the coordinator
2744    /// again first. The rebalance codes (`22`, `25`, `27`, `82` after a rejoin)
2745    /// are not retried: `commit_sync` raises `RebalanceInProgress` or
2746    /// `CommitFailed` straight through, with no offsets committed, exactly as
2747    /// Kafka's `commitOffsetsSync` does.
2748    #[tokio::test]
2749    async fn commit_sync_handles_each_offset_commit_error_code_as_kafka_does() {
2750        let mut actual = Vec::new();
2751        let mut wanted = Vec::new();
2752        for (name, answers, rejoin, retry_policy, expected) in offset_commit_error_code_cases() {
2753            actual.push((
2754                name,
2755                scripted_commit_sync(answers, rejoin, retry_policy, Duration::ZERO).await,
2756            ));
2757            wanted.push((name, expected));
2758        }
2759        let wanted: Vec<(&str, CommitCodeResult)> = wanted;
2760        assert2::assert!(actual == wanted);
2761    }
2762
2763    /// The retry policy of a commit that retries at once.
2764    const RETRY: CoordinatorRetryPolicy = CoordinatorRetryPolicy {
2765        timeout: Duration::from_secs(2),
2766        initial_backoff: Duration::from_millis(1),
2767        max_backoff: Duration::from_millis(1),
2768    };
2769
2770    /// The backoff is longer than the time that is left. The timeout leaves
2771    /// room for the real round trips to the mock coordinator of a slow,
2772    /// instrumented test run before it expires.
2773    const LONG_BACKOFF: CoordinatorRetryPolicy = CoordinatorRetryPolicy {
2774        timeout: Duration::from_secs(1),
2775        initial_backoff: Duration::from_secs(5),
2776        max_backoff: Duration::from_secs(5),
2777    };
2778
2779    /// The table of cases for
2780    /// [`commit_sync_handles_each_offset_commit_error_code_as_kafka_does`],
2781    /// split out to keep that test's own body under clippy's line-count
2782    /// limit.
2783    fn offset_commit_error_code_cases() -> Vec<(
2784        &'static str,
2785        Vec<CommitAnswer>,
2786        bool,
2787        CoordinatorRetryPolicy,
2788        CommitCodeResult,
2789    )> {
2790        use CommitAnswer::{Close, Codes, Silent};
2791
2792        let fenced = described(&ConsumerError::FencedInstanceId("instance-a".into()));
2793        let commit_failed = described(&ConsumerError::CommitFailed);
2794        let rebalance_in_progress =
2795            described(&ConsumerError::RebalanceInProgress("group-a".into()));
2796        let timed_out = |cause: ConsumerError| (COMMIT_TIMED_OUT.into(), Some(cause.to_string()));
2797        let disconnected = timed_out(ConsumerError::Client(ClientError::Disconnected));
2798        let group_denied = described(&ConsumerError::GroupAuthorizationFailed("group-a".into()));
2799        let topics_denied = described(&ConsumerError::TopicAuthorizationFailed(
2800            maplit::btreeset! {"orders".into(), "payments".into()},
2801        ));
2802        vec![
2803            ("success", vec![Codes(&[])], false, RETRY, (Ok(()), 1, 0)),
2804            (
2805                "unknown topic or partition retries",
2806                vec![Codes(&[("orders", 3)]), Codes(&[])],
2807                false,
2808                RETRY,
2809                (Ok(()), 2, 0),
2810            ),
2811            (
2812                "coordinator load in progress retries",
2813                vec![Codes(&[("orders", 14)]), Codes(&[])],
2814                false,
2815                RETRY,
2816                (Ok(()), 2, 0),
2817            ),
2818            (
2819                "request timed out finds the coordinator and retries",
2820                vec![Codes(&[("orders", 7)]), Codes(&[])],
2821                false,
2822                RETRY,
2823                (Ok(()), 2, 1),
2824            ),
2825            (
2826                "coordinator not available finds the coordinator and retries",
2827                vec![Codes(&[("orders", 15)]), Codes(&[])],
2828                false,
2829                RETRY,
2830                (Ok(()), 2, 1),
2831            ),
2832            (
2833                "not coordinator finds the coordinator and retries",
2834                vec![Codes(&[("orders", 16)]), Codes(&[])],
2835                false,
2836                RETRY,
2837                (Ok(()), 2, 1),
2838            ),
2839            (
2840                "unknown topic or partition past the timeout",
2841                vec![Codes(&[("orders", 3)])],
2842                false,
2843                LONG_BACKOFF,
2844                (Err(timed_out(ConsumerError::Server(3))), 1, 0),
2845            ),
2846            (
2847                "not coordinator with a backoff past the timeout",
2848                vec![Codes(&[("orders", 16)]), Codes(&[])],
2849                false,
2850                LONG_BACKOFF,
2851                (Err(timed_out(ConsumerError::Server(16))), 1, 1),
2852            ),
2853            (
2854                "disconnect finds the coordinator and retries",
2855                vec![Close, Codes(&[])],
2856                false,
2857                RETRY,
2858                (Ok(()), 2, 1),
2859            ),
2860            (
2861                "request timeout finds the coordinator and retries",
2862                vec![Silent, Codes(&[])],
2863                false,
2864                RETRY,
2865                (Ok(()), 2, 1),
2866            ),
2867            (
2868                "disconnect past the timeout",
2869                vec![Close],
2870                false,
2871                LONG_BACKOFF,
2872                (Err(disconnected), 1, 1),
2873            ),
2874            (
2875                "group authorization failed",
2876                vec![Codes(&[("orders", 30)])],
2877                false,
2878                RETRY,
2879                (Err(group_denied), 1, 0),
2880            ),
2881            (
2882                "topic authorization failed collects every topic",
2883                vec![Codes(&[("orders", 29), ("payments", 29)])],
2884                false,
2885                RETRY,
2886                (Err(topics_denied), 1, 0),
2887            ),
2888            (
2889                "topic authorization failed with a retriable code retries",
2890                vec![Codes(&[("orders", 29), ("payments", 3)]), Codes(&[])],
2891                false,
2892                RETRY,
2893                (Ok(()), 2, 0),
2894            ),
2895            (
2896                "offset metadata too large",
2897                vec![Codes(&[("orders", 12)])],
2898                false,
2899                RETRY,
2900                (Err(described(&ConsumerError::Server(12))), 1, 0),
2901            ),
2902            (
2903                "invalid commit offset size",
2904                vec![Codes(&[("orders", 28)])],
2905                false,
2906                RETRY,
2907                (Err(described(&ConsumerError::Server(28))), 1, 0),
2908            ),
2909            (
2910                "unknown topic id is unexpected for a request by topic name",
2911                vec![Codes(&[("orders", 100)])],
2912                false,
2913                RETRY,
2914                (Err(described(&ConsumerError::Server(100))), 1, 0),
2915            ),
2916            (
2917                "not leader or follower is unexpected",
2918                vec![Codes(&[("orders", 6)])],
2919                false,
2920                RETRY,
2921                (Err(described(&ConsumerError::Server(6))), 1, 0),
2922            ),
2923            (
2924                "fenced instance id with the same generation",
2925                vec![Codes(&[("orders", 82)])],
2926                false,
2927                RETRY,
2928                (Err(fenced), 1, 0),
2929            ),
2930            (
2931                "fenced instance id is a stale artifact of an already-completed rejoin",
2932                vec![Codes(&[("orders", 82)]), Codes(&[])],
2933                true,
2934                RETRY,
2935                (Err(rebalance_in_progress.clone()), 1, 0),
2936            ),
2937            (
2938                "illegal generation with the request's own generation still current: a real kick",
2939                vec![Codes(&[("orders", 22)])],
2940                false,
2941                RETRY,
2942                (Err(commit_failed.clone()), 1, 0),
2943            ),
2944            (
2945                "illegal generation is a stale artifact of an already-completed rejoin",
2946                vec![Codes(&[("orders", 22)]), Codes(&[])],
2947                true,
2948                RETRY,
2949                (Err(rebalance_in_progress.clone()), 1, 0),
2950            ),
2951            (
2952                "unknown member id with the request's own generation still current: a real kick",
2953                vec![Codes(&[("orders", 25)])],
2954                false,
2955                RETRY,
2956                (Err(commit_failed), 1, 0),
2957            ),
2958            (
2959                "unknown member id is a stale artifact of an already-completed rejoin",
2960                vec![Codes(&[("orders", 25)]), Codes(&[])],
2961                true,
2962                RETRY,
2963                (Err(rebalance_in_progress.clone()), 1, 0),
2964            ),
2965            (
2966                "rebalance in progress fails the commit",
2967                vec![Codes(&[("orders", 27)])],
2968                false,
2969                RETRY,
2970                (Err(rebalance_in_progress.clone()), 1, 0),
2971            ),
2972            (
2973                "rebalance in progress fails the commit even after a rejoin",
2974                vec![Codes(&[("orders", 27)]), Codes(&[])],
2975                true,
2976                RETRY,
2977                (Err(rebalance_in_progress), 1, 0),
2978            ),
2979        ]
2980    }
2981
2982    /// A synchronous commit that first waits for the commit lock retries
2983    /// until the deadline of the whole call, and at that deadline times out
2984    /// with the last retriable error as the cause, as the same commit without
2985    /// the wait does. The wait does not move the retry deadline past the
2986    /// bound of the call.
2987    #[tokio::test]
2988    async fn commit_sync_after_a_wait_for_the_commit_lock_times_out_with_the_last_retriable_error()
2989    {
2990        let retry_policy = CoordinatorRetryPolicy {
2991            timeout: Duration::from_secs(1),
2992            initial_backoff: Duration::from_secs(5),
2993            max_backoff: Duration::from_secs(5),
2994        };
2995        let result = scripted_commit_sync(
2996            vec![CommitAnswer::Codes(&[("orders", 3)])],
2997            false,
2998            retry_policy,
2999            Duration::from_millis(50),
3000        )
3001        .await;
3002        let timed_out = (
3003            COMMIT_TIMED_OUT.into(),
3004            Some(ConsumerError::Server(3).to_string()),
3005        );
3006        assert2::assert!(result == (Err(timed_out), 1, 0));
3007    }
3008
3009    /// Run `commit_sync` against a mock coordinator that gives `answers` to
3010    /// the `OffsetCommit` requests in order and repeats the last answer. With
3011    /// `rejoin`, the coordinator task joins the group again with generation 8
3012    /// before the first answer. Another holder keeps the commit lock for
3013    /// `commit_lock_held` after `commit_sync` starts.
3014    async fn scripted_commit_sync(
3015        answers: Vec<CommitAnswer>,
3016        rejoin: bool,
3017        retry_policy: CoordinatorRetryPolicy,
3018        commit_lock_held: Duration,
3019    ) -> CommitCodeResult {
3020        use CommitAnswer::{Close, Codes, Silent};
3021        use krabka_protocol::owned::{
3022            find_coordinator_response::FindCoordinatorResponse, metadata_response::MetadataResponse,
3023        };
3024
3025        let identity = Arc::new(Mutex::new(CommitIdentity {
3026            generation: 7,
3027            member_id: "member-a".into(),
3028            ownership_ids: maplit::hashmap! {("orders".into(), 0) => 1, ("payments".into(), 0) => 2},
3029            rejoin_on_poll: false,
3030        }));
3031        let assignment_changed = Arc::new(Notify::new());
3032        let offset_commits = Arc::new(AtomicUsize::new(0));
3033        let find_coordinators = Arc::new(AtomicUsize::new(0));
3034        let identity_in_mock = Arc::clone(&identity);
3035        let changed_in_mock = Arc::clone(&assignment_changed);
3036        let offset_commits_in_mock = Arc::clone(&offset_commits);
3037        let find_coordinators_in_mock = Arc::clone(&find_coordinators);
3038        let mock = MockBroker::start_with_replies(move |api_key, version, _corr_id, mut body| {
3039            let mut buffer = bytes::BytesMut::new();
3040            match api_key {
3041                api_versions_request::API_KEY => MockReply::Respond(
3042                    group_api_versions(offset_commit_request::API_KEY, (7, 7)).unwrap(),
3043                ),
3044                find_coordinator_request::API_KEY => {
3045                    find_coordinators_in_mock.fetch_add(1, Ordering::SeqCst);
3046                    FindCoordinatorResponse::default()
3047                        .encode(&mut buffer, version)
3048                        .unwrap();
3049                    MockReply::Respond(buffer.to_vec())
3050                }
3051                metadata_request::API_KEY => {
3052                    MetadataResponse::default()
3053                        .encode(&mut buffer, version)
3054                        .unwrap();
3055                    MockReply::Respond(buffer.to_vec())
3056                }
3057                offset_commit_request::API_KEY => {
3058                    let attempt = offset_commits_in_mock.fetch_add(1, Ordering::SeqCst);
3059                    if attempt == 0 && rejoin {
3060                        identity_in_mock.try_lock().unwrap().generation = 8;
3061                        changed_in_mock.notify_waiters();
3062                    }
3063                    let client_id_len = bytes::Buf::get_i16(&mut body);
3064                    bytes::Buf::advance(&mut body, usize::try_from(client_id_len).unwrap());
3065                    let request: OffsetCommitRequest =
3066                        krabka_protocol::Decode::decode(&mut body, version).unwrap();
3067                    let codes = match answers[attempt.min(answers.len() - 1)] {
3068                        Codes(codes) => codes,
3069                        Close => return MockReply::Close,
3070                        Silent => return MockReply::Silent,
3071                    };
3072                    let code_of = |topic: &str| {
3073                        codes
3074                            .iter()
3075                            .find(|(name, _)| *name == topic)
3076                            .map_or(0, |(_, code)| *code)
3077                    };
3078                    let response = offset_commit_response(&request, code_of);
3079                    MockReply::Respond(test_support::encode_protocol_response(&response, version))
3080                }
3081                _ => MockReply::Silent,
3082            }
3083        })
3084        .await
3085        .unwrap();
3086        let mut consumer = commit_consumer(
3087            CommitConsumerStart::builder()
3088                .mock(&mock)
3089                .commit_identity(Arc::clone(&identity))
3090                .assignment_changed(assignment_changed)
3091                .build(),
3092        )
3093        .await;
3094        consumer.client = Client::builder()
3095            .bootstrap(mock.addr.to_string())
3096            .request_timeout(krabka_units::millis(300))
3097            .build()
3098            .await
3099            .unwrap();
3100        consumer.group_instance_id = Some("instance-a".into());
3101        consumer.retry_policy = retry_policy;
3102        consumer.default_api_timeout = convert::StdDurationExt::as_time(&retry_policy.timeout);
3103
3104        let held = consumer.commit_serialization.lock().await;
3105        let release = async move {
3106            sleep(commit_lock_held).await;
3107            drop(held);
3108        };
3109        let (result, ()) = tokio::join!(
3110            timeout(Duration::from_secs(10), consumer.commit_sync()),
3111            release,
3112        );
3113        let result = result
3114            .map_err(|_| ("commit never finished".to_owned(), None))
3115            .and_then(|result| result.map_err(|error| described(&error)));
3116
3117        mock.stop();
3118        (
3119            result,
3120            offset_commits.load(Ordering::SeqCst),
3121            find_coordinators.load(Ordering::SeqCst),
3122        )
3123    }
3124
3125    /// A silent broker cannot keep `commit_sync` blocked past
3126    /// `default_api_timeout`, even when the client's own `request_timeout` is
3127    /// far longer. `default_api_timeout` must bound the whole awaited commit
3128    /// operation, not only whether another retry attempt starts, exactly as
3129    /// Kafka's `commitSync(Duration timeout)` bounds the whole call.
3130    #[tokio::test]
3131    async fn commit_sync_times_out_near_default_api_timeout_despite_a_silent_broker() {
3132        let mock = MockBroker::start(move |api_key, _version, _corr_id, _body| {
3133            if api_key == api_versions_request::API_KEY {
3134                return Some(group_api_versions(offset_commit_request::API_KEY, (7, 7)).unwrap());
3135            }
3136            // Every other request, including `OffsetCommit`, gets no reply at
3137            // all: the broker is up but never answers.
3138            None
3139        })
3140        .await
3141        .unwrap();
3142
3143        let mut consumer = commit_consumer(
3144            CommitConsumerStart::builder()
3145                .mock(&mock)
3146                .commit_identity(commit_identity(7, "member-a"))
3147                .build(),
3148        )
3149        .await;
3150        // The client's own request timeout is far longer than the commit's
3151        // API timeout, so only `default_api_timeout` can end this call.
3152        consumer.client = Client::builder()
3153            .bootstrap(mock.addr.to_string())
3154            .request_timeout(secs(30))
3155            .build()
3156            .await
3157            .unwrap();
3158        consumer.default_api_timeout = krabka_units::millis(200);
3159
3160        let started = Instant::now();
3161        let result = timeout(Duration::from_secs(5), consumer.commit_sync())
3162            .await
3163            .expect("commit_sync must return on its own well before the test's own timeout");
3164        let elapsed = started.elapsed();
3165
3166        mock.stop();
3167        check!(
3168            result.map_err(|error| described(&error))
3169                == Err((
3170                    "timeout: Timeout of 200ms expired before successfully committing offsets"
3171                        .into(),
3172                    None
3173                ))
3174        );
3175        check!(elapsed < Duration::from_secs(2));
3176    }
3177
3178    /// A commit fails without a request after the coordinator task stopped, and
3179    /// after the coordinator fenced the static member until the next `poll`
3180    /// joins the group again. Kafka's
3181    /// `ConsumerCoordinator.sendOffsetCommitRequest` raises
3182    /// `CommitFailedException` when the member is not part of an active group.
3183    #[tokio::test]
3184    async fn commit_fails_without_a_request_after_the_coordinator_task_stops() {
3185        #[derive(Clone, Copy)]
3186        enum Commit {
3187            All,
3188            Selected,
3189        }
3190        #[derive(Clone, Copy)]
3191        enum TaskState {
3192            Running,
3193            /// The task runs and waits for the next `poll` after a fence.
3194            Fenced,
3195            Stopping,
3196            Stopped,
3197        }
3198        let commit_failed = "offset commit failed: the consumer is not part of an active group; it is likely that the consumer was kicked out of the group";
3199        for (name, commit, task_state, owned, expected) in [
3200            (
3201                "commit sync while the task runs",
3202                Commit::All,
3203                TaskState::Running,
3204                true,
3205                (Ok(()), 1),
3206            ),
3207            (
3208                "commit sync after close cancelled the task but before it ends",
3209                Commit::All,
3210                TaskState::Stopping,
3211                true,
3212                (Err(commit_failed.to_owned()), 0),
3213            ),
3214            (
3215                "commit sync while the task waits for a poll after a fence",
3216                Commit::All,
3217                TaskState::Fenced,
3218                false,
3219                (Err(commit_failed.to_owned()), 0),
3220            ),
3221            (
3222                "selected commit while the task waits for a poll after a fence",
3223                Commit::Selected,
3224                TaskState::Fenced,
3225                false,
3226                (Err(commit_failed.to_owned()), 0),
3227            ),
3228            (
3229                "commit sync after the task stops",
3230                Commit::All,
3231                TaskState::Stopped,
3232                true,
3233                (Err(commit_failed.to_owned()), 0),
3234            ),
3235            (
3236                "commit sync after a fence cleared the assignment",
3237                Commit::All,
3238                TaskState::Stopped,
3239                false,
3240                (Err(commit_failed.to_owned()), 0),
3241            ),
3242            (
3243                "selected commit after the task stops",
3244                Commit::Selected,
3245                TaskState::Stopped,
3246                true,
3247                (Err(commit_failed.to_owned()), 0),
3248            ),
3249        ] {
3250            let requests = Arc::new(AtomicUsize::new(0));
3251            let requests_in_mock = Arc::clone(&requests);
3252            let mock = MockBroker::start(move |api_key, version, _corr_id, _body| {
3253                if api_key == api_versions_request::API_KEY {
3254                    return Some(
3255                        group_api_versions(offset_commit_request::API_KEY, (2, 2)).unwrap(),
3256                    );
3257                }
3258                if api_key != offset_commit_request::API_KEY {
3259                    return None;
3260                }
3261                requests_in_mock.fetch_add(1, Ordering::SeqCst);
3262                Some(test_support::encode_protocol_response(
3263                    &response(&[0]),
3264                    version,
3265                ))
3266            })
3267            .await
3268            .unwrap();
3269            let identity = commit_identity(7, "member-a");
3270            if !owned {
3271                identity.lock().await.ownership_ids.clear();
3272            }
3273            if matches!(task_state, TaskState::Fenced) {
3274                identity.lock().await.rejoin_on_poll = true;
3275            }
3276            let mut consumer = commit_consumer(
3277                CommitConsumerStart::builder()
3278                    .mock(&mock)
3279                    .commit_identity(identity)
3280                    .build(),
3281            )
3282            .await;
3283            let task = match task_state {
3284                TaskState::Running | TaskState::Fenced => tokio::spawn(future::pending::<()>()),
3285                TaskState::Stopping => {
3286                    consumer.coordinator_shutdown.cancel();
3287                    tokio::spawn(future::pending::<()>())
3288                }
3289                TaskState::Stopped => {
3290                    let task = tokio::spawn(async {});
3291                    while !task.is_finished() {
3292                        yield_now().await;
3293                    }
3294                    task
3295                }
3296            };
3297            consumer.coordinator_handle = Some(task);
3298
3299            let commit_future = async {
3300                match commit {
3301                    Commit::All => consumer.commit_sync().await,
3302                    Commit::Selected => consumer
3303                        .commit_offsets_sync(
3304                            maplit::hashmap! {("topic".into(), 0) => OffsetAndMetadata::new(12)},
3305                        )
3306                        .await,
3307                }
3308            };
3309            let result = timeout(Duration::from_secs(5), commit_future)
3310                .await
3311                .unwrap_or_else(|_| panic!("case {name}: commit never finished"))
3312                .map_err(|error| error.to_string());
3313
3314            if let Some(task) = consumer.coordinator_handle.take() {
3315                task.abort();
3316            }
3317            mock.stop();
3318            check!(
3319                (result, requests.load(Ordering::SeqCst)) == expected,
3320                "case {name}"
3321            );
3322        }
3323    }
3324
3325    /// Issue #116: a rebalance-class code does not wait for the coordinator
3326    /// task to rejoin and resend. `commit_offsets_sync` raises the error from
3327    /// the one response it got, even though a rejoin happened concurrently.
3328    #[tokio::test]
3329    async fn selected_commit_fails_immediately_on_rebalance_in_progress_without_retrying() {
3330        let requests = Arc::new(AtomicUsize::new(0));
3331        let SelectedCommitConsumer { consumer, mock, .. } = SelectedCommitConsumerStart::builder()
3332            .requests(Arc::clone(&requests))
3333            .on_first_request(advance_generation_on_first_commit())
3334            .build()
3335            .start()
3336            .await;
3337
3338        let result = timeout(Duration::from_secs(2), selected_commit(&consumer, 12))
3339            .await
3340            .expect("commit does not wait for a rejoin");
3341
3342        mock.stop();
3343        assert2::assert!(
3344            result.map_err(|error| error.to_string())
3345                == Err(ConsumerError::RebalanceInProgress("group-a".into()).to_string())
3346        );
3347        assert2::assert!(requests.load(Ordering::SeqCst) == 1);
3348    }
3349
3350    /// The request that fails with a rebalance code still carries the
3351    /// generation and the member id that `commit_offsets_sync` snapshotted at
3352    /// its call, not the ones a concurrent rejoin published afterward.
3353    #[tokio::test]
3354    async fn selected_commit_uses_snapshot_generation_if_ownership_changes_before_rpc() {
3355        let identity = commit_identity(7, "member-a");
3356        let generation = Arc::new(AtomicI32::new(7));
3357        let seen_generation = Arc::new(AtomicI32::new(-1));
3358        let seen_in_mock = Arc::clone(&seen_generation);
3359        let SelectedCommitConsumer { consumer, mock, .. } = SelectedCommitConsumerStart::builder()
3360            .commit_identity(Arc::clone(&identity))
3361            .generation(Arc::clone(&generation))
3362            .on_first_request(
3363                move |_ownership: &Arc<Mutex<CommitIdentity>>,
3364                      _generation: &Arc<AtomicI32>,
3365                      request_generation: i32,
3366                      _request_member_id: &str| {
3367                    seen_in_mock.store(request_generation, Ordering::Relaxed);
3368                },
3369            )
3370            .build()
3371            .start()
3372            .await;
3373        let mut changed_identity = identity.lock().await;
3374        changed_identity
3375            .ownership_ids
3376            .insert(("topic".into(), 0), 2);
3377        changed_identity.generation = 8;
3378        drop(changed_identity);
3379        generation.store(8, Ordering::Relaxed);
3380        let topics = build_commit_topics(
3381            maplit::hashmap! {("topic".into(), 0) => OffsetAndMetadata::new(12)},
3382        );
3383
3384        let error = consumer
3385            .commit_topics_once(topics, (7, "member-a".into()))
3386            .await
3387            .expect_err("rebalance in progress fails the commit");
3388
3389        mock.stop();
3390        assert2::assert!(
3391            error.to_string() == ConsumerError::RebalanceInProgress("group-a".into()).to_string()
3392        );
3393        assert2::assert!(seen_generation.load(Ordering::Relaxed) == 7);
3394    }
3395
3396    /// The one request a selected commit sends carries the live member id from
3397    /// `commit_identity`, even though it is never retried.
3398    #[tokio::test]
3399    async fn selected_commit_uses_live_member_id_even_without_a_retry() {
3400        let identity = commit_identity(8, "member-new");
3401        let seen_new_member = Arc::new(AtomicBool::new(false));
3402        let seen_in_mock = Arc::clone(&seen_new_member);
3403        let SelectedCommitConsumer { consumer, mock, .. } = SelectedCommitConsumerStart::builder()
3404            .commit_identity(identity)
3405            .on_first_request(
3406                move |_identity: &Arc<Mutex<CommitIdentity>>,
3407                      generation: &Arc<AtomicI32>,
3408                      _request_generation: i32,
3409                      request_member_id: &str| {
3410                    seen_in_mock.store(request_member_id == "member-new", Ordering::Relaxed);
3411                    generation.store(8, Ordering::Release);
3412                },
3413            )
3414            .build()
3415            .start()
3416            .await;
3417        let result = consumer
3418            .commit_offsets_sync(
3419                maplit::hashmap! {("topic".into(), 0) => OffsetAndMetadata::new(12)},
3420            )
3421            .await;
3422
3423        mock.stop();
3424        assert2::assert!(result.is_err());
3425        assert2::assert!(seen_new_member.load(Ordering::Relaxed));
3426    }
3427
3428    /// A rebalance code on any one partition of a mixed response fails the
3429    /// whole commit, even though another partition of the same response
3430    /// acknowledged.
3431    #[tokio::test]
3432    async fn commit_sync_fails_on_any_rebalance_code_in_a_mixed_response() {
3433        let identity = Arc::new(Mutex::new(CommitIdentity {
3434            generation: 7,
3435            member_id: "member-a".into(),
3436            ownership_ids: maplit::hashmap! {("topic".into(), 0) => 1, ("topic".into(), 1) => 2},
3437            rejoin_on_poll: false,
3438        }));
3439        let requests = Arc::new(AtomicUsize::new(0));
3440        let SelectedCommitConsumer { consumer, mock, .. } = SelectedCommitConsumerStart::builder()
3441            .commit_identity(identity)
3442            .requests(Arc::clone(&requests))
3443            .mixed_response(true)
3444            .on_first_request(advance_generation_on_first_commit())
3445            .build()
3446            .start()
3447            .await;
3448
3449        let result = consumer.commit_sync().await;
3450
3451        mock.stop();
3452        assert2::assert!(
3453            result.map_err(|error| error.to_string())
3454                == Err(ConsumerError::RebalanceInProgress("group-a".into()).to_string())
3455        );
3456        assert2::assert!(requests.load(Ordering::SeqCst) == 1);
3457    }
3458
3459    /// A commit that fails with a rebalance error still releases
3460    /// `commit_serialization` so a newer commit for the same partition sends
3461    /// only after it, never before.
3462    #[tokio::test]
3463    async fn a_failed_commit_releases_the_lock_before_a_newer_commit_for_the_same_partition() {
3464        let identity = commit_identity(7, "member-a");
3465        let requests = Arc::new(AtomicUsize::new(0));
3466        let SelectedCommitConsumer {
3467            consumer,
3468            mock,
3469            seen_offsets,
3470            ..
3471        } = SelectedCommitConsumerStart::builder()
3472            .commit_identity(identity)
3473            .requests(requests)
3474            .on_first_request(advance_generation_on_first_commit())
3475            .build()
3476            .start()
3477            .await;
3478        let consumer = Arc::new(consumer);
3479        let blocker = Arc::clone(&consumer.commit_serialization)
3480            .lock_owned()
3481            .await;
3482        let older = {
3483            let consumer = Arc::clone(&consumer);
3484            tokio::spawn(async move {
3485                consumer
3486                    .commit_offsets_sync(
3487                        maplit::hashmap! {("topic".into(), 0) => OffsetAndMetadata::new(10)},
3488                    )
3489                    .await
3490            })
3491        };
3492        yield_now().await;
3493        let newer = {
3494            let consumer = Arc::clone(&consumer);
3495            tokio::spawn(async move {
3496                consumer
3497                    .commit_offsets_sync(
3498                        maplit::hashmap! {("topic".into(), 0) => OffsetAndMetadata::new(12)},
3499                    )
3500                    .await
3501            })
3502        };
3503        yield_now().await;
3504        drop(blocker);
3505
3506        let older_error = older
3507            .await
3508            .unwrap()
3509            .expect_err("the response for the older commit is a rebalance error");
3510        newer
3511            .await
3512            .unwrap()
3513            .expect("the newer commit sends once the older one released the lock");
3514
3515        mock.stop();
3516        assert2::assert!(
3517            older_error.to_string()
3518                == ConsumerError::RebalanceInProgress("group-a".into()).to_string()
3519        );
3520        assert2::assert!(*seen_offsets.lock().unwrap() == vec![(0, 10), (0, 12)]);
3521    }
3522
3523    #[tokio::test]
3524    async fn async_commits_queued_behind_an_rpc_are_coalesced() {
3525        let SelectedCommitConsumer {
3526            consumer,
3527            mock,
3528            requests,
3529            ..
3530        } = SelectedCommitConsumerStart::builder()
3531            .on_first_request(
3532                |_: &Arc<Mutex<CommitIdentity>>, _: &Arc<AtomicI32>, _: i32, _: &str| {},
3533            )
3534            .build()
3535            .start()
3536            .await;
3537        let blocker = consumer.commit_serialization.lock().await;
3538
3539        for _ in 0..100 {
3540            consumer.commit_async().expect("commit_async");
3541        }
3542        yield_now().await;
3543        assert2::assert!(consumer.commit_async_state.load(Ordering::Acquire) == ASYNC_COMMIT_DIRTY);
3544        drop(blocker);
3545
3546        wait_for_async_commit_idle(&consumer, Duration::from_secs(2)).await;
3547        mock.stop();
3548
3549        assert2::assert!(requests.load(Ordering::SeqCst) == 1);
3550    }
3551
3552    #[test]
3553    fn selected_commit_topics_exclude_unrequested_assignment_positions() {
3554        let topics = build_commit_topics(
3555            maplit::hashmap! {("topic".into(), 0) => OffsetAndMetadata::new(12)},
3556        );
3557
3558        assert2::assert!(topics.len() == 1);
3559        assert2::assert!(topics[0].partitions.len() == 1);
3560        assert2::assert!(topics[0].partitions[0].partition_index == 0);
3561    }
3562
3563    /// The `OffsetCommit` requests that [`recording_coordinator`] received.
3564    type SentCommits = Arc<StdMutex<Vec<VersionedOffsetCommit>>>;
3565
3566    /// A coordinator that records each `OffsetCommit` v7 request and answers
3567    /// each partition with `error_code`.
3568    async fn recording_coordinator(error_code: i16) -> OffsetCommitCoordinator {
3569        OffsetCommitCoordinatorOptions::builder()
3570            .error_code(error_code)
3571            .build()
3572            .start()
3573            .await
3574    }
3575
3576    struct CallbackCommitFixture {
3577        consumer: Consumer,
3578        mock: MockBroker,
3579        sent: SentCommits,
3580    }
3581
3582    async fn callback_commit_fixture(error_code: i16) -> CallbackCommitFixture {
3583        let OffsetCommitCoordinator {
3584            mock,
3585            requests: sent,
3586        } = recording_coordinator(error_code).await;
3587        let consumer = commit_consumer(
3588            CommitConsumerStart::builder()
3589                .mock(&mock)
3590                .commit_identity(commit_identity(7, "member-a"))
3591                .build(),
3592        )
3593        .await;
3594        CallbackCommitFixture {
3595            consumer,
3596            mock,
3597            sent,
3598        }
3599    }
3600
3601    fn sent_partitions(sent: &SentCommits) -> Vec<OffsetCommitRequestPartition> {
3602        sent.lock()
3603            .unwrap()
3604            .iter()
3605            .flat_map(|sent| sent.request.topics.iter())
3606            .flat_map(|topic| topic.partitions.iter().cloned())
3607            .collect()
3608    }
3609
3610    /// Kafka's `commitSync(offsets)` sends each offset with its leader epoch
3611    /// and metadata, also an offset past the consumed position. Kafka's
3612    /// `commitSync()` sends the position with its leader epoch.
3613    #[tokio::test]
3614    async fn synchronous_commits_send_kafkas_offsets_epochs_and_metadata() {
3615        let mut actual = Vec::new();
3616        let mut wanted = Vec::new();
3617        for (name, selected, expected) in [
3618            (
3619                "selected offset past the position",
3620                Some(OffsetAndMetadata {
3621                    offset: 100,
3622                    leader_epoch: Some(4),
3623                    metadata: "note".into(),
3624                }),
3625                (100, 4, "note"),
3626            ),
3627            (
3628                "selected offset without epoch",
3629                Some(OffsetAndMetadata::new(3)),
3630                (3, -1, ""),
3631            ),
3632            ("positions", None, (12, 9, "")),
3633        ] {
3634            let OffsetCommitCoordinator {
3635                mock,
3636                requests: sent,
3637            } = recording_coordinator(0).await;
3638            let consumer = default_commit_consumer(&mock).await;
3639            consumer.positions.lock().await.insert(
3640                ("topic".into(), 0),
3641                PartitionPosition {
3642                    offset_epoch: krabka_ids::LeaderEpoch(9),
3643                    ..Default::default()
3644                },
3645            );
3646            let result = match selected {
3647                Some(offset) => {
3648                    consumer
3649                        .commit_offsets_sync(maplit::hashmap! {("topic".into(), 0) => offset})
3650                        .await
3651                }
3652                None => consumer.commit_sync().await,
3653            };
3654            mock.stop();
3655            actual.push((
3656                name,
3657                result.map_err(|error| error.to_string()),
3658                sent_partitions(&sent),
3659            ));
3660            wanted.push((
3661                name,
3662                Ok(()),
3663                vec![OffsetCommitRequestPartition {
3664                    partition_index: 0,
3665                    committed_offset: expected.0,
3666                    committed_leader_epoch: expected.1,
3667                    committed_metadata: Some(expected.2.into()),
3668                    ..Default::default()
3669                }],
3670            ));
3671        }
3672        assert2::assert!(actual == wanted);
3673    }
3674
3675    /// A failed coordinator lookup of an asynchronous commit comes to the
3676    /// callback as `RetriableCommitFailedException` (Kafka's
3677    /// `ConsumerCoordinator.commitOffsetsAsync`). Other errors stay as they
3678    /// are.
3679    #[test]
3680    fn asynchronous_commit_wraps_a_failed_coordinator_lookup() {
3681        let actual = [
3682            ConsumerError::Server(15),
3683            ConsumerError::Server(14),
3684            ConsumerError::CoordinatorUnavailable,
3685            ConsumerError::Server(30),
3686            ConsumerError::CommitFailed,
3687        ]
3688        .map(|error| {
3689            async_commit_result(Err(error), "group-a", None).map_err(|error| error.to_string())
3690        });
3691        let retriable = |cause: &str| {
3692            Err(format!(
3693                "offset commit failed with a retriable exception: {cause}"
3694            ))
3695        };
3696        assert2::assert!(
3697            actual
3698                == [
3699                    retriable("broker error_code 15"),
3700                    retriable("broker error_code 14"),
3701                    retriable("coordinator unavailable"),
3702                    Err("broker error_code 30".to_owned()),
3703                    Err("offset commit failed: the consumer is not part of an active group; it is likely that the consumer was kicked out of the group".to_owned()),
3704                ]
3705        );
3706    }
3707
3708    /// Kafka's `commitAsync(callback)` calls the callback with the offsets and
3709    /// the result. A retriable error comes as `RetriableCommitFailedException`.
3710    #[tokio::test]
3711    async fn asynchronous_commit_calls_the_callback_with_offsets_and_result() {
3712        let mut actual = Vec::new();
3713        let mut wanted = Vec::new();
3714        for (name, error_code, expected) in [
3715            ("success", 0, Ok(())),
3716            (
3717                "coordinator loading",
3718                14,
3719                Err("offset commit failed with a retriable exception: broker error_code 14"),
3720            ),
3721            (
3722                "group authorization",
3723                30,
3724                Err("not authorized to access group: group-a"),
3725            ),
3726            ("metadata too large", 12, Err("broker error_code 12")),
3727        ] {
3728            let CallbackCommitFixture { consumer, mock, .. } =
3729                callback_commit_fixture(error_code).await;
3730            let (tx, rx) = oneshot::channel();
3731            consumer
3732                .commit_async_with_callback(move |offsets, result| {
3733                    let _ = tx.send((offsets.clone(), result.map_err(ToString::to_string)));
3734                })
3735                .expect("commit_async_with_callback");
3736            let completed = timeout(Duration::from_secs(5), rx)
3737                .await
3738                .expect("callback runs")
3739                .expect("callback sends");
3740            mock.stop();
3741            actual.push((name, completed));
3742            wanted.push((
3743                name,
3744                (
3745                    maplit::hashmap! {("topic".to_owned(), 0) => OffsetAndMetadata::new(12)},
3746                    expected.map_err(str::to_owned),
3747                ),
3748            ));
3749        }
3750        assert2::assert!(actual == wanted);
3751    }
3752
3753    /// Kafka completes an asynchronous commit of no offsets locally with
3754    /// success, and calls the callback.
3755    #[tokio::test]
3756    async fn asynchronous_commit_without_positions_calls_the_callback() {
3757        let CallbackCommitFixture {
3758            consumer,
3759            mock,
3760            sent,
3761        } = callback_commit_fixture(0).await;
3762        consumer.next_offsets.lock().await.clear();
3763        let (tx, rx) = oneshot::channel();
3764        consumer
3765            .commit_async_with_callback(move |offsets, result| {
3766                let _ = tx.send((offsets.clone(), result.map_err(ToString::to_string)));
3767            })
3768            .expect("commit_async_with_callback");
3769        let completed = timeout(Duration::from_secs(5), rx)
3770            .await
3771            .expect("callback runs")
3772            .expect("callback sends");
3773        mock.stop();
3774        assert2::assert!((completed, sent_partitions(&sent)) == ((HashMap::new(), Ok(())), vec![]));
3775    }
3776
3777    /// Kafka's `onJoinPrepare` commits the fetch positions. An explicit offset
3778    /// reaches the commit before a `JoinGroup` only after the coordinator
3779    /// acknowledged it, so a rejected offset past the position is never
3780    /// committed by the auto commit.
3781    #[tokio::test]
3782    async fn explicit_offsets_reach_the_pre_join_commit_only_after_an_ack() {
3783        let mut actual = Vec::new();
3784        let mut wanted = Vec::new();
3785        for (name, error_code, expected) in [
3786            (
3787                "acknowledged",
3788                0,
3789                maplit::hashmap! {("topic".to_owned(), 0) => (100, 4)},
3790            ),
3791            ("metadata too large", 12, HashMap::new()),
3792        ] {
3793            let OffsetCommitCoordinator { mock, .. } = recording_coordinator(error_code).await;
3794            let mut consumer = default_commit_consumer(&mock).await;
3795            let auto_commit = AutoCommit::new(Duration::from_secs(5));
3796            consumer.auto_commit = Some(auto_commit.clone());
3797            let _ = consumer
3798                .commit_offsets_sync(maplit::hashmap! {("topic".into(), 0) => OffsetAndMetadata {
3799                    offset: 100,
3800                    leader_epoch: Some(4),
3801                    metadata: "note".into(),
3802                }})
3803                .await;
3804            mock.stop();
3805            let ownership = consumer.commit_identity.lock().await.ownership_ids.clone();
3806            actual.push((name, auto_commit.polled_offsets(&ownership).await));
3807            wanted.push((name, expected));
3808        }
3809        assert2::assert!(actual == wanted);
3810    }
3811
3812    /// A panic in an application callback does not stop later asynchronous
3813    /// commits.
3814    #[tokio::test]
3815    async fn asynchronous_commits_continue_after_a_callback_panics() {
3816        let OffsetCommitCoordinator { mock, .. } = recording_coordinator(0).await;
3817        let consumer = default_commit_consumer(&mock).await;
3818        consumer
3819            .commit_async_with_callback(|_, _| panic!("callback panics"))
3820            .expect("commit_async_with_callback");
3821        wait_for_async_commit_idle(&consumer, Duration::from_secs(5)).await;
3822        let (tx, rx) = oneshot::channel();
3823        consumer
3824            .commit_async_with_callback(move |_, result| {
3825                let _ = tx.send(result.map_err(ToString::to_string));
3826            })
3827            .expect("commit_async_with_callback");
3828        let second = timeout(Duration::from_secs(5), rx).await;
3829        mock.stop();
3830        assert2::assert!(let Ok(Ok(Ok(()))) = second);
3831    }
3832
3833    /// A consumer with a manual assignment has no coordinator task that joins
3834    /// the group again, so a rebalance error fails its commit with
3835    /// `CommitFailed`, as Kafka's `commitOffsetsSync` throws
3836    /// `CommitFailedException`.
3837    #[tokio::test]
3838    async fn a_rebalance_error_fails_the_commit_of_a_manual_assignment() {
3839        let OffsetCommitCoordinator { mock, .. } = recording_coordinator(ILLEGAL_GENERATION).await;
3840        let mut consumer = commit_consumer(
3841            CommitConsumerStart::builder()
3842                .mock(&mock)
3843                .commit_identity(commit_identity(-1, ""))
3844                .generation(Arc::new(AtomicI32::new(-1)))
3845                .build(),
3846        )
3847        .await;
3848        consumer.subscription = shared(Vec::new(), None, true);
3849        consumer
3850            .subscription
3851            .send_modify(|subscription| subscription.manual_assignment = true);
3852        let result = timeout(Duration::from_secs(5), selected_commit(&consumer, 12))
3853            .await
3854            .map(|result| result.map_err(|error| error.to_string()));
3855        mock.stop();
3856        assert2::assert!(result == Ok(Err(ConsumerError::CommitFailed.to_string())));
3857    }
3858}