1use 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
47const UNKNOWN_TOPIC_OR_PARTITION: i16 = 3;
49const REQUEST_TIMED_OUT: i16 = 7;
51const ILLEGAL_GENERATION: i16 = 22;
53const UNKNOWN_MEMBER_ID: i16 = 25;
55const REBALANCE_IN_PROGRESS: i16 = 27;
57const TOPIC_AUTHORIZATION_FAILED: i16 = 29;
59const GROUP_AUTHORIZATION_FAILED: i16 = 30;
61const FENCED_INSTANCE_ID: i16 = 82;
63
64#[derive(Clone, Debug, Default, PartialEq, Eq)]
67pub struct OffsetAndMetadata {
68 pub offset: i64,
70 pub leader_epoch: Option<i32>,
72 pub metadata: String,
75}
76
77impl OffsetAndMetadata {
78 #[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 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
99pub 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 let epoch = positions.get(&k).map_or(-1, |p| p.offset_epoch.get());
124 (k, (v, epoch))
125 })
126 .collect()
127}
128
129fn 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
143fn 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
168async 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
200pub type OffsetCommitCallback =
205 Box<dyn FnOnce(&HashMap<(String, i32), OffsetAndMetadata>, Result<(), &ConsumerError>) + Send>;
206
207pub type OffsetCommitCallbacks = Arc<StdMutex<Vec<OffsetCommitCallback>>>;
209
210fn take_callbacks(callbacks: &OffsetCommitCallbacks) -> Vec<OffsetCommitCallback> {
212 mem::take(&mut *callbacks.lock().unwrap_or_else(PoisonError::into_inner))
213}
214
215fn 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 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
286fn 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
326pub 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 Retriable {
351 code: i16,
352 acknowledged: HashSet<(String, i32)>,
353 find_coordinator: bool,
356 },
357}
358
359#[derive(Clone, Copy, Debug, Eq, PartialEq)]
364enum RebalanceCommitFailure {
365 CommitFailed,
370 RebalanceInProgress,
376}
377
378#[derive(Clone, Copy, Debug)]
381struct CommitResponseContext<'a> {
382 group_id: &'a str,
383 group_instance_id: Option<&'a str>,
384 coordinator_alive: bool,
386 generation_unchanged: bool,
389}
390
391fn 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
511pub struct ConsumedPosition {
512 pub offset: i64,
513 pub leader_epoch: i32,
514 pub ownership_id: u64,
517}
518
519pub type ConsumedPositions = HashMap<(String, i32), ConsumedPosition>;
521
522pub type PendingOffsets = HashMap<(String, i32), (OffsetAndMetadata, u64)>;
524
525fn has_valid_position(next_offset: i64) -> bool {
530 next_offset >= 0 && !is_reset_sentinel(next_offset)
531}
532
533pub 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
561fn 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
576fn 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
599fn 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
621enum RecordSent {
622 BeforeSend,
626 AfterAck,
631}
632
633#[derive(Clone, Copy, Debug, Eq, PartialEq)]
635pub enum AutoCommitOutcome {
636 Committed,
637 Retriable,
640 Failed,
641}
642
643#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
647enum PartitionCommitError {
648 None,
649 TopicAuthorization,
653 Retriable,
656 CoordinatorUnknown,
659 Rebalance,
662 FencedInstanceId,
665 GroupAuthorization,
668 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 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
705pub fn names_moved_coordinator(response: &OffsetCommitResponse) -> bool {
710 PartitionCommitError::of_response(response) == PartitionCommitError::CoordinatorUnknown
711}
712
713pub 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#[derive(Clone, Debug)]
754pub struct AutoCommit {
755 interval: Duration,
757 next_due: Arc<Mutex<Instant>>,
760 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 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 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 pub(crate) async fn restart_interval(&self) {
828 *self.next_due.lock().await = Instant::now() + self.interval;
829 }
830
831 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#[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 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)] struct 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 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 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 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 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 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 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 #[cfg_attr(test, mutants::skip)] #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[cfg_attr(test, mutants::skip)] #[tracing::instrument(
1457 name = "consumer.commit_async",
1458 level = "debug",
1459 skip_all,
1460 fields(commit_context = ?commit_trace_context(self))
1461 )]
1462 pub fn commit_async(&self) -> Result<(), ConsumerError> {
1467 self.require_group_id()?;
1468 self.schedule_commit_async();
1469 Ok(())
1470 }
1471
1472 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 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 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 if let Err(error) = &result {
1575 tracing::warn!(%error, "commit_async failed");
1576 }
1577 for callback in waiting {
1578 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 type CommitResult = Result<(), (i16, i16, i16, i16, i16)>;
2615
2616 #[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 #[derive(Clone, Copy, Debug)]
2709 enum CommitAnswer {
2710 Codes(&'static [(&'static str, i16)]),
2713 Close,
2715 Silent,
2717 }
2718
2719 type CommitCodeResult = (Result<(), (String, Option<String>)>, usize, usize);
2723
2724 const COMMIT_TIMED_OUT: &str =
2729 "timeout: Timeout of 1000ms expired before successfully committing offsets";
2730
2731 fn described(error: &ConsumerError) -> (String, Option<String>) {
2734 (
2735 error.to_string(),
2736 Error::source(error).map(ToString::to_string),
2737 )
2738 }
2739
2740 #[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 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 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 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 #[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 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 #[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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 type SentCommits = Arc<StdMutex<Vec<VersionedOffsetCommit>>>;
3565
3566 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}